rollout: add slow command analysis

This commit is contained in:
Adam Perry
2026-06-18 22:02:38 +00:00
parent 78a9e169bb
commit a5a99c49d8
10 changed files with 1705 additions and 0 deletions
+1
View File
@@ -3754,6 +3754,7 @@ dependencies = [
"codex-login",
"codex-otel",
"codex-protocol",
"codex-shell-command",
"codex-state",
"codex-utils-path",
"pretty_assertions",
+12
View File
@@ -55,6 +55,7 @@ mod plugin_cmd;
mod remote_control_cmd;
#[cfg(target_os = "windows")]
mod sandbox_setup;
mod slow_commands;
mod state_db_recovery;
#[cfg(not(windows))]
mod wsl_paths;
@@ -234,6 +235,9 @@ enum DebugSubcommand {
/// Render the model-visible prompt input list as JSON.
PromptInput(DebugPromptInputCommand),
/// Analyze direct shell-command wait time in local rollout history.
SlowCommands(slow_commands::SlowCommandsCommand),
/// Replay a rollout trace bundle and write reduced state JSON.
#[clap(hide = true)]
TraceReduce(DebugTraceReduceCommand),
@@ -1517,6 +1521,14 @@ async fn cli_main(
)
.await?;
}
DebugSubcommand::SlowCommands(cmd) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"debug slow-commands",
)?;
slow_commands::run(cmd).await?;
}
DebugSubcommand::TraceReduce(cmd) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
+354
View File
@@ -0,0 +1,354 @@
use anyhow::Context;
use anyhow::Result;
use clap::Args;
use clap::Subcommand;
use codex_core::config::find_codex_home;
use codex_rollout::SlowCommandAggregate;
use codex_rollout::SlowCommandCollection;
use codex_rollout::SlowCommandContinuation;
use codex_rollout::SlowCommandDirectCall;
use codex_rollout::SlowCommandSummary;
use codex_rollout::analyze_slow_commands;
use codex_rollout::collect_slow_commands;
use codex_rollout::merge_slow_command_collections;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use std::io::BufRead;
use std::io::BufWriter;
use std::io::Write;
use std::time::Duration;
const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Args)]
pub(crate) struct SlowCommandsCommand {
#[command(subcommand)]
action: Option<SlowCommandsAction>,
}
#[derive(Debug, Subcommand)]
enum SlowCommandsAction {
/// Emit mergeable low-level command observations as JSONL.
Collect,
/// Read concatenated collection JSONL from stdin and emit one analysis.
Merge,
}
#[derive(Deserialize, Serialize)]
struct SummaryRecord {
#[serde(rename = "type")]
record_type: String,
schema_version: u32,
rollout_files_seen: usize,
rollout_files_analyzed: usize,
rollout_files_skipped: usize,
internal_rollout_files_skipped: usize,
rollout_lines_skipped: usize,
direct_shell_calls_seen: usize,
direct_shell_calls_analyzed: usize,
direct_shell_calls_skipped: usize,
continuation_calls_seen: usize,
continuation_calls_attributed: usize,
continuation_calls_skipped: usize,
duplicate_call_ids: usize,
open_processes_at_eof: usize,
code_mode_cells_ignored: usize,
total_wait_seconds: f64,
}
impl SummaryRecord {
fn new(record_type: &str, summary: &SlowCommandSummary) -> Self {
Self {
record_type: record_type.to_string(),
schema_version: SCHEMA_VERSION,
rollout_files_seen: summary.rollout_files_seen,
rollout_files_analyzed: summary.rollout_files_analyzed,
rollout_files_skipped: summary.rollout_files_skipped,
internal_rollout_files_skipped: summary.internal_rollout_files_skipped,
rollout_lines_skipped: summary.rollout_lines_skipped,
direct_shell_calls_seen: summary.direct_shell_calls_seen,
direct_shell_calls_analyzed: summary.direct_shell_calls_analyzed,
direct_shell_calls_skipped: summary.direct_shell_calls_skipped,
continuation_calls_seen: summary.continuation_calls_seen,
continuation_calls_attributed: summary.continuation_calls_attributed,
continuation_calls_skipped: summary.continuation_calls_skipped,
duplicate_call_ids: summary.duplicate_call_ids,
open_processes_at_eof: summary.open_processes_at_eof,
code_mode_cells_ignored: summary.code_mode_cells_ignored,
total_wait_seconds: seconds(summary.total_wait),
}
}
fn into_collection_summary(self) -> SlowCommandSummary {
SlowCommandSummary {
rollout_files_seen: self.rollout_files_seen,
rollout_files_analyzed: self.rollout_files_analyzed,
rollout_files_skipped: self.rollout_files_skipped,
internal_rollout_files_skipped: self.internal_rollout_files_skipped,
rollout_lines_skipped: self.rollout_lines_skipped,
duplicate_call_ids: self.duplicate_call_ids,
code_mode_cells_ignored: self.code_mode_cells_ignored,
..Default::default()
}
}
}
#[derive(Serialize)]
struct RankingRecord<'a> {
#[serde(rename = "type")]
record_type: &'static str,
schema_version: u32,
rank: usize,
#[serde(skip_serializing_if = "Option::is_none")]
command: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
executable: Option<&'a str>,
invocation_count: usize,
continuation_wait_count: usize,
total_wait_seconds: f64,
average_wait_seconds: f64,
max_wait_seconds: f64,
}
#[derive(Deserialize, Serialize)]
struct DirectCallRecord {
#[serde(rename = "type")]
record_type: String,
schema_version: u32,
call_id: String,
command: Option<String>,
wait_seconds: Option<f64>,
observed_running: bool,
observed_terminal: bool,
}
#[derive(Deserialize, Serialize)]
struct ContinuationRecord {
#[serde(rename = "type")]
record_type: String,
schema_version: u32,
call_id: String,
invocation_call_id: Option<String>,
wait_seconds: Option<f64>,
observed_running: bool,
observed_terminal: bool,
}
#[derive(Clone, Copy)]
enum RankingKind {
ExactCommand,
CommandFamily,
}
struct FixedPrecisionFormatter;
impl serde_json::ser::Formatter for FixedPrecisionFormatter {
fn write_f64<W>(&mut self, writer: &mut W, value: f64) -> std::io::Result<()>
where
W: ?Sized + Write,
{
write!(writer, "{value:.4}")
}
}
pub(crate) async fn run(command: SlowCommandsCommand) -> Result<()> {
let mut output = BufWriter::new(std::io::stdout().lock());
match command.action {
None => {
let codex_home = find_codex_home()?;
let analysis = analyze_slow_commands(codex_home.as_path()).await;
write_analysis(&mut output, &analysis)?;
}
Some(SlowCommandsAction::Collect) => {
let codex_home = find_codex_home()?;
let collection = collect_slow_commands(codex_home.as_path()).await;
write_collection(&mut output, &collection)?;
}
Some(SlowCommandsAction::Merge) => {
let collections = read_collections(std::io::stdin().lock())?;
let analysis = merge_slow_command_collections(collections);
write_analysis(&mut output, &analysis)?;
}
}
output.flush()?;
Ok(())
}
fn write_analysis(
output: &mut impl Write,
analysis: &codex_rollout::SlowCommandAnalysis,
) -> Result<()> {
write_json_line(output, &SummaryRecord::new("summary", &analysis.summary))?;
for (index, aggregate) in analysis.exact_commands.iter().enumerate() {
write_ranking(output, RankingKind::ExactCommand, index, aggregate)?;
}
for (index, aggregate) in analysis.command_families.iter().enumerate() {
write_ranking(output, RankingKind::CommandFamily, index, aggregate)?;
}
Ok(())
}
fn write_collection(output: &mut impl Write, collection: &SlowCommandCollection) -> Result<()> {
write_json_line(
output,
&SummaryRecord::new("collection_summary", &collection.summary),
)?;
for direct_call in &collection.direct_calls {
write_json_line(
output,
&DirectCallRecord {
record_type: "direct_call".to_string(),
schema_version: SCHEMA_VERSION,
call_id: direct_call.call_id.clone(),
command: direct_call.command.clone(),
wait_seconds: direct_call.wait.map(seconds),
observed_running: direct_call.observed_running,
observed_terminal: direct_call.observed_terminal,
},
)?;
}
for continuation in &collection.continuations {
write_json_line(
output,
&ContinuationRecord {
record_type: "continuation".to_string(),
schema_version: SCHEMA_VERSION,
call_id: continuation.call_id.clone(),
invocation_call_id: continuation.invocation_call_id.clone(),
wait_seconds: continuation.wait.map(seconds),
observed_running: continuation.observed_running,
observed_terminal: continuation.observed_terminal,
},
)?;
}
Ok(())
}
fn read_collections(reader: impl BufRead) -> Result<Vec<SlowCommandCollection>> {
let mut collections = Vec::new();
let mut current: Option<SlowCommandCollection> = None;
for (index, line) in reader.lines().enumerate() {
let line_number = index.saturating_add(1);
let line = line.with_context(|| format!("failed to read collection line {line_number}"))?;
if line.trim().is_empty() {
continue;
}
let value: Value = serde_json::from_str(&line)
.with_context(|| format!("invalid JSON on collection line {line_number}"))?;
let record_type = value
.get("type")
.and_then(Value::as_str)
.with_context(|| format!("missing record type on collection line {line_number}"))?;
match record_type {
"collection_summary" => {
if let Some(collection) = current.take() {
collections.push(collection);
}
let record: SummaryRecord = serde_json::from_value(value)
.with_context(|| format!("invalid collection summary on line {line_number}"))?;
validate_schema_version(record.schema_version, line_number)?;
current = Some(SlowCommandCollection {
summary: record.into_collection_summary(),
..Default::default()
});
}
"direct_call" => {
let record: DirectCallRecord = serde_json::from_value(value)
.with_context(|| format!("invalid direct call on line {line_number}"))?;
validate_schema_version(record.schema_version, line_number)?;
current
.as_mut()
.with_context(|| {
format!("direct call before collection summary on line {line_number}")
})?
.direct_calls
.push(SlowCommandDirectCall {
call_id: record.call_id,
command: record.command,
wait: parse_duration(record.wait_seconds, line_number)?,
observed_running: record.observed_running,
observed_terminal: record.observed_terminal,
});
}
"continuation" => {
let record: ContinuationRecord = serde_json::from_value(value)
.with_context(|| format!("invalid continuation on line {line_number}"))?;
validate_schema_version(record.schema_version, line_number)?;
current
.as_mut()
.with_context(|| {
format!("continuation before collection summary on line {line_number}")
})?
.continuations
.push(SlowCommandContinuation {
call_id: record.call_id,
invocation_call_id: record.invocation_call_id,
wait: parse_duration(record.wait_seconds, line_number)?,
observed_running: record.observed_running,
observed_terminal: record.observed_terminal,
});
}
other => {
anyhow::bail!("unsupported record type `{other}` on collection line {line_number}")
}
}
}
if let Some(collection) = current {
collections.push(collection);
}
Ok(collections)
}
fn validate_schema_version(schema_version: u32, line_number: usize) -> Result<()> {
if schema_version != SCHEMA_VERSION {
anyhow::bail!(
"unsupported schema version {schema_version} on collection line {line_number}"
);
}
Ok(())
}
fn parse_duration(seconds: Option<f64>, line_number: usize) -> Result<Option<Duration>> {
seconds
.map(Duration::try_from_secs_f64)
.transpose()
.with_context(|| format!("invalid wait duration on collection line {line_number}"))
}
fn write_ranking(
output: &mut impl Write,
kind: RankingKind,
index: usize,
aggregate: &SlowCommandAggregate,
) -> Result<()> {
let (record_type, command, executable) = match kind {
RankingKind::ExactCommand => ("exact_command", Some(aggregate.key.as_str()), None),
RankingKind::CommandFamily => ("command_family", None, Some(aggregate.key.as_str())),
};
let record = RankingRecord {
record_type,
schema_version: SCHEMA_VERSION,
rank: index.saturating_add(1),
command,
executable,
invocation_count: aggregate.invocation_count,
continuation_wait_count: aggregate.continuation_wait_count,
total_wait_seconds: seconds(aggregate.total_wait),
average_wait_seconds: seconds(aggregate.average_wait()),
max_wait_seconds: seconds(aggregate.max_wait),
};
write_json_line(output, &record)
}
fn write_json_line(output: &mut impl Write, value: &impl Serialize) -> Result<()> {
let mut serializer =
serde_json::Serializer::with_formatter(&mut *output, FixedPrecisionFormatter);
value.serialize(&mut serializer)?;
output.write_all(b"\n")?;
Ok(())
}
fn seconds(duration: Duration) -> f64 {
duration.as_secs_f64()
}
+161
View File
@@ -0,0 +1,161 @@
use anyhow::Result;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::fs;
use std::io::Write;
use std::path::Path;
use tempfile::TempDir;
fn write_rollout(codex_home: &Path) -> Result<()> {
let id = "019ed917-334f-7393-8070-2b72069cd533";
let dir = codex_home.join("sessions/2026/06/18");
fs::create_dir_all(&dir)?;
let mut file = fs::File::create(dir.join(format!("rollout-2026-06-18T12-00-00-{id}.jsonl")))?;
for value in [
json!({
"timestamp": "2026-06-18T12:00:00Z",
"type": "session_meta",
"payload": {
"id": id,
"timestamp": "2026-06-18T12:00:00Z",
"cwd": codex_home,
"originator": "test",
"cli_version": "test",
"source": "cli",
"model_provider": null,
},
}),
call("test", "cargo test -p codex-cli"),
output(
"test",
"Wall time: 1.5000 seconds\nProcess exited with code 0\nOutput:",
),
call("check", "cargo check"),
output(
"check",
"Wall time: 0.5000 seconds\nProcess exited with code 0\nOutput:",
),
] {
writeln!(file, "{value}")?;
}
Ok(())
}
fn call(call_id: &str, command: &str) -> Value {
json!({
"timestamp": "2026-06-18T12:00:01Z",
"type": "response_item",
"payload": {
"type": "function_call",
"name": "exec_command",
"arguments": json!({"cmd": command}).to_string(),
"call_id": call_id,
},
})
}
fn output(call_id: &str, text: &str) -> Value {
json!({
"timestamp": "2026-06-18T12:00:02Z",
"type": "response_item",
"payload": {"type": "function_call_output", "call_id": call_id, "output": text},
})
}
fn run(codex_home: &Path) -> Result<String> {
run_with(codex_home, &[], None)
}
fn run_with(codex_home: &Path, args: &[&str], stdin: Option<&str>) -> Result<String> {
let mut command = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
command
.env("CODEX_HOME", codex_home)
.args(["debug", "slow-commands"])
.args(args);
if let Some(stdin) = stdin {
command.write_stdin(stdin);
}
let output = command.output()?;
assert!(output.status.success(), "{output:?}");
let stdout = String::from_utf8(output.stdout)?;
for line in stdout.lines() {
serde_json::from_str::<Value>(line)?;
}
Ok(stdout)
}
#[test]
fn slow_commands_prints_versioned_ranked_jsonl() -> Result<()> {
let home = TempDir::new()?;
write_rollout(home.path())?;
let stdout = run(home.path())?;
assert_eq!(
stdout.lines().collect::<Vec<_>>(),
vec![
r#"{"type":"summary","schema_version":1,"rollout_files_seen":1,"rollout_files_analyzed":1,"rollout_files_skipped":0,"internal_rollout_files_skipped":0,"rollout_lines_skipped":0,"direct_shell_calls_seen":2,"direct_shell_calls_analyzed":2,"direct_shell_calls_skipped":0,"continuation_calls_seen":0,"continuation_calls_attributed":0,"continuation_calls_skipped":0,"duplicate_call_ids":0,"open_processes_at_eof":0,"code_mode_cells_ignored":0,"total_wait_seconds":2.0000}"#,
r#"{"type":"exact_command","schema_version":1,"rank":1,"command":"cargo test -p codex-cli","invocation_count":1,"continuation_wait_count":0,"total_wait_seconds":1.5000,"average_wait_seconds":1.5000,"max_wait_seconds":1.5000}"#,
r#"{"type":"exact_command","schema_version":1,"rank":2,"command":"cargo check","invocation_count":1,"continuation_wait_count":0,"total_wait_seconds":0.5000,"average_wait_seconds":0.5000,"max_wait_seconds":0.5000}"#,
r#"{"type":"command_family","schema_version":1,"rank":1,"executable":"cargo","invocation_count":2,"continuation_wait_count":0,"total_wait_seconds":2.0000,"average_wait_seconds":1.0000,"max_wait_seconds":1.5000}"#,
]
);
assert!(stdout.ends_with('\n'));
Ok(())
}
#[test]
fn slow_commands_empty_home_prints_only_summary() -> Result<()> {
let home = TempDir::new()?;
let stdout = run(home.path())?;
assert_eq!(
stdout,
concat!(
r#"{"type":"summary","schema_version":1,"rollout_files_seen":0,"rollout_files_analyzed":0,"rollout_files_skipped":0,"internal_rollout_files_skipped":0,"rollout_lines_skipped":0,"direct_shell_calls_seen":0,"direct_shell_calls_analyzed":0,"direct_shell_calls_skipped":0,"continuation_calls_seen":0,"continuation_calls_attributed":0,"continuation_calls_skipped":0,"duplicate_call_ids":0,"open_processes_at_eof":0,"code_mode_cells_ignored":0,"total_wait_seconds":0.0000}"#,
"\n"
)
);
Ok(())
}
#[test]
fn slow_commands_collections_round_trip_and_deduplicate_across_machines() -> Result<()> {
let first_home = TempDir::new()?;
let second_home = TempDir::new()?;
write_rollout(first_home.path())?;
write_rollout(second_home.path())?;
let first = run_with(first_home.path(), &["collect"], None)?;
let second = run_with(second_home.path(), &["collect"], None)?;
let collected = first
.lines()
.map(serde_json::from_str::<Value>)
.collect::<serde_json::Result<Vec<_>>>()?;
assert_eq!(collected.len(), 3);
assert_eq!(collected[0]["type"], "collection_summary");
assert_eq!(collected[1]["type"], "direct_call");
assert_eq!(collected[1]["call_id"], "check");
assert_eq!(collected[1]["wait_seconds"].as_f64(), Some(0.5));
let input = format!("{first}{second}");
let merged = run_with(first_home.path(), &["merge"], Some(&input))?;
let records = merged
.lines()
.map(serde_json::from_str::<Value>)
.collect::<serde_json::Result<Vec<_>>>()?;
assert_eq!(records.len(), 4);
assert_eq!(records[0]["type"], "summary");
assert_eq!(records[0]["rollout_files_seen"], 2);
assert_eq!(records[0]["direct_shell_calls_seen"], 2);
assert_eq!(records[0]["duplicate_call_ids"], 2);
assert_eq!(records[0]["total_wait_seconds"].as_f64(), Some(2.0));
assert_eq!(records[1]["command"], "cargo test -p codex-cli");
assert_eq!(records[1]["invocation_count"], 1);
assert_eq!(records[3]["executable"], "cargo");
assert_eq!(records[3]["invocation_count"], 2);
Ok(())
}
+1
View File
@@ -20,6 +20,7 @@ codex-git-utils = { workspace = true }
codex-login = { workspace = true }
codex-otel = { workspace = true }
codex-protocol = { workspace = true }
codex-shell-command = { workspace = true }
codex-state = { workspace = true }
codex-utils-path = { workspace = true }
regex = { workspace = true }
+10
View File
@@ -12,6 +12,7 @@ pub(crate) mod policy;
pub(crate) mod recorder;
pub(crate) mod search;
pub(crate) mod session_index;
mod slow_commands;
mod sqlite_metrics;
pub mod state_db;
@@ -74,6 +75,15 @@ pub use session_index::find_thread_meta_by_name_str;
pub use session_index::find_thread_name_by_id;
pub use session_index::find_thread_names_by_ids;
pub use session_index::remove_thread_name_entries;
pub use slow_commands::SlowCommandAggregate;
pub use slow_commands::SlowCommandAnalysis;
pub use slow_commands::SlowCommandCollection;
pub use slow_commands::SlowCommandContinuation;
pub use slow_commands::SlowCommandDirectCall;
pub use slow_commands::SlowCommandSummary;
pub use slow_commands::analyze_slow_commands;
pub use slow_commands::collect_slow_commands;
pub use slow_commands::merge_slow_command_collections;
pub use state_db::StateDbHandle;
pub use state_db::sqlite_telemetry_recorder;
+421
View File
@@ -0,0 +1,421 @@
use crate::ARCHIVED_SESSIONS_SUBDIR;
use crate::SESSIONS_SUBDIR;
use crate::compression;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionSource;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
mod merge;
mod parsing;
pub use merge::merge_slow_command_collections;
use merge::analyze_observations;
use merge::merge_optional_duration;
use merge::merge_optional_string;
use parsing::parse_direct_command;
use parsing::parse_running_session_id;
use parsing::parse_session_id;
use parsing::parse_wall_time;
/// Coverage and accounting data for a slow-command analysis.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SlowCommandSummary {
pub rollout_files_seen: usize,
pub rollout_files_analyzed: usize,
pub rollout_files_skipped: usize,
pub internal_rollout_files_skipped: usize,
pub rollout_lines_skipped: usize,
pub direct_shell_calls_seen: usize,
pub direct_shell_calls_analyzed: usize,
pub direct_shell_calls_skipped: usize,
pub continuation_calls_seen: usize,
pub continuation_calls_attributed: usize,
pub continuation_calls_skipped: usize,
pub duplicate_call_ids: usize,
pub open_processes_at_eof: usize,
pub code_mode_cells_ignored: usize,
pub total_wait: Duration,
}
/// Aggregate wait-time statistics for one exact command or executable family.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SlowCommandAggregate {
pub key: String,
pub invocation_count: usize,
pub continuation_wait_count: usize,
pub total_wait: Duration,
pub max_wait: Duration,
}
impl SlowCommandAggregate {
pub fn average_wait(&self) -> Duration {
if self.invocation_count == 0 {
return Duration::ZERO;
}
Duration::from_secs_f64(self.total_wait.as_secs_f64() / self.invocation_count as f64)
}
}
/// Slow-command analysis over all eligible rollout files in a Codex home.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SlowCommandAnalysis {
pub summary: SlowCommandSummary,
pub exact_commands: Vec<SlowCommandAggregate>,
pub command_families: Vec<SlowCommandAggregate>,
}
/// Mergeable low-level observations collected from one Codex home.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SlowCommandCollection {
pub summary: SlowCommandSummary,
pub direct_calls: Vec<SlowCommandDirectCall>,
pub continuations: Vec<SlowCommandContinuation>,
}
/// One observed direct `shell_command` or `exec_command` call.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SlowCommandDirectCall {
pub call_id: String,
pub command: Option<String>,
pub wait: Option<Duration>,
pub observed_running: bool,
pub observed_terminal: bool,
}
/// One observed `write_stdin` call and its optional originating command.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SlowCommandContinuation {
pub call_id: String,
pub invocation_call_id: Option<String>,
pub wait: Option<Duration>,
pub observed_running: bool,
pub observed_terminal: bool,
}
enum PendingCall {
Direct,
Continuation { session_id: Option<i64> },
}
#[derive(Default)]
struct FileAnalysisState {
pending_calls: HashMap<String, PendingCall>,
active_processes: HashMap<i64, String>,
}
#[derive(Default)]
struct AnalysisState {
summary: SlowCommandSummary,
direct_calls: HashMap<String, SlowCommandDirectCall>,
continuations: HashMap<String, SlowCommandContinuation>,
relevant_call_ids: HashSet<String>,
code_mode_call_ids: HashSet<String>,
}
/// Analyzes direct agent shell calls in active and archived rollout history.
pub async fn analyze_slow_commands(codex_home: &Path) -> SlowCommandAnalysis {
merge_slow_command_collections([collect_slow_commands(codex_home).await])
}
/// Collects mergeable direct-call and continuation observations from a Codex home.
pub async fn collect_slow_commands(codex_home: &Path) -> SlowCommandCollection {
let mut state = AnalysisState::default();
let paths = discover_rollout_paths(codex_home, &mut state.summary).await;
state.summary.rollout_files_seen = paths.len();
for path in paths {
analyze_rollout(&path, &mut state).await;
}
finish_collection(state)
}
async fn discover_rollout_paths(
codex_home: &Path,
summary: &mut SlowCommandSummary,
) -> Vec<PathBuf> {
let mut dirs = vec![
codex_home.join(SESSIONS_SUBDIR),
codex_home.join(ARCHIVED_SESSIONS_SUBDIR),
];
let mut files = BTreeMap::<PathBuf, PathBuf>::new();
while let Some(dir) = dirs.pop() {
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
Err(_) => {
summary.rollout_files_skipped = summary.rollout_files_skipped.saturating_add(1);
continue;
}
};
loop {
let entry = match entries.next_entry().await {
Ok(Some(entry)) => entry,
Ok(None) => break,
Err(_) => {
summary.rollout_files_skipped = summary.rollout_files_skipped.saturating_add(1);
break;
}
};
let path = entry.path();
let file_type = match entry.file_type().await {
Ok(file_type) => file_type,
Err(_) => {
summary.rollout_files_skipped = summary.rollout_files_skipped.saturating_add(1);
continue;
}
};
if file_type.is_dir() {
dirs.push(path);
continue;
}
if !file_type.is_file() {
continue;
}
let Some(rollout_file) = compression::RolloutFile::from_path(path) else {
continue;
};
let canonical_path = compression::plain_rollout_path(rollout_file.path());
files.insert(canonical_path, rollout_file.into_path());
}
}
files.into_values().collect()
}
async fn analyze_rollout(path: &Path, state: &mut AnalysisState) {
let mut reader = match compression::open_rollout_line_reader(path).await {
Ok(reader) => reader,
Err(_) => {
state.summary.rollout_files_skipped =
state.summary.rollout_files_skipped.saturating_add(1);
return;
}
};
let mut file_state = FileAnalysisState::default();
let mut saw_session_meta = false;
loop {
let line = match reader.next_line().await {
Ok(Some(line)) => line,
Ok(None) => break,
Err(_) => {
state.summary.rollout_files_skipped =
state.summary.rollout_files_skipped.saturating_add(1);
break;
}
};
if line.trim().is_empty() {
continue;
}
let rollout_line = match serde_json::from_str::<RolloutLine>(&line) {
Ok(rollout_line) => rollout_line,
Err(_) => {
state.summary.rollout_lines_skipped =
state.summary.rollout_lines_skipped.saturating_add(1);
continue;
}
};
match rollout_line.item {
RolloutItem::SessionMeta(meta) if !saw_session_meta => {
saw_session_meta = true;
if matches!(meta.meta.source, SessionSource::Internal(_)) {
state.summary.internal_rollout_files_skipped = state
.summary
.internal_rollout_files_skipped
.saturating_add(1);
return;
}
}
RolloutItem::ResponseItem(item) => {
analyze_response_item(item, &mut file_state, state);
}
RolloutItem::SessionMeta(_)
| RolloutItem::InterAgentCommunication(_)
| RolloutItem::Compacted(_)
| RolloutItem::TurnContext(_)
| RolloutItem::EventMsg(_) => {}
}
}
state.summary.rollout_files_analyzed = state.summary.rollout_files_analyzed.saturating_add(1);
}
fn analyze_response_item(
item: ResponseItem,
file_state: &mut FileAnalysisState,
state: &mut AnalysisState,
) {
match item {
ResponseItem::FunctionCall {
name,
namespace,
arguments,
call_id,
..
} if namespace.is_none() => match name.as_str() {
"shell_command" | "exec_command" => {
record_relevant_call_id(&call_id, state);
let command = parse_direct_command(&name, &arguments);
let direct_call = state
.direct_calls
.entry(call_id.clone())
.or_insert_with(|| SlowCommandDirectCall {
call_id: call_id.clone(),
command: None,
wait: None,
observed_running: false,
observed_terminal: false,
});
merge_optional_string(&mut direct_call.command, command);
file_state
.pending_calls
.insert(call_id, PendingCall::Direct);
}
"write_stdin" => {
record_relevant_call_id(&call_id, state);
state
.continuations
.entry(call_id.clone())
.or_insert_with(|| SlowCommandContinuation {
call_id: call_id.clone(),
invocation_call_id: None,
wait: None,
observed_running: false,
observed_terminal: false,
});
file_state.pending_calls.insert(
call_id,
PendingCall::Continuation {
session_id: parse_session_id(&arguments),
},
);
}
_ => {}
},
ResponseItem::FunctionCallOutput {
call_id, output, ..
} => {
let Some(pending) = file_state.pending_calls.remove(&call_id) else {
return;
};
let text = output.body.to_text().unwrap_or_default();
match pending {
PendingCall::Direct => {
record_direct_output(&call_id, &text, file_state, state);
}
PendingCall::Continuation { session_id } => {
record_continuation_output(&call_id, session_id, &text, file_state, state);
}
}
}
ResponseItem::CustomToolCall { call_id, name, .. } if name == "exec" => {
state.code_mode_call_ids.insert(call_id);
}
ResponseItem::Message { .. }
| ResponseItem::AgentMessage { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::CompactionTrigger { .. }
| ResponseItem::ContextCompaction { .. }
| ResponseItem::Other => {}
}
}
fn record_relevant_call_id(call_id: &str, state: &mut AnalysisState) {
if !state.relevant_call_ids.insert(call_id.to_string()) {
state.summary.duplicate_call_ids = state.summary.duplicate_call_ids.saturating_add(1);
}
}
fn record_direct_output(
call_id: &str,
output: &str,
file_state: &mut FileAnalysisState,
state: &mut AnalysisState,
) {
let Some(direct_call) = state.direct_calls.get_mut(call_id) else {
return;
};
if let Some(duration) = parse_wall_time(output) {
merge_optional_duration(&mut direct_call.wait, Some(duration));
}
if let Some(session_id) = parse_running_session_id(output) {
direct_call.observed_running = true;
file_state
.active_processes
.insert(session_id, call_id.to_string());
} else {
direct_call.observed_terminal = true;
}
}
fn record_continuation_output(
call_id: &str,
session_id: Option<i64>,
output: &str,
file_state: &mut FileAnalysisState,
state: &mut AnalysisState,
) {
let Some(continuation) = state.continuations.get_mut(call_id) else {
return;
};
if let Some(duration) = parse_wall_time(output) {
merge_optional_duration(&mut continuation.wait, Some(duration));
}
let observed_running = parse_running_session_id(output).is_some();
if observed_running {
continuation.observed_running = true;
} else {
continuation.observed_terminal = true;
}
let Some(session_id) = session_id else {
return;
};
let Some(invocation_id) = file_state.active_processes.get(&session_id).cloned() else {
return;
};
merge_optional_string(&mut continuation.invocation_call_id, Some(invocation_id));
if !observed_running {
file_state.active_processes.remove(&session_id);
}
}
fn finish_collection(mut state: AnalysisState) -> SlowCommandCollection {
let mut direct_calls = state.direct_calls.into_values().collect::<Vec<_>>();
direct_calls.sort_by(|left, right| left.call_id.cmp(&right.call_id));
let mut continuations = state.continuations.into_values().collect::<Vec<_>>();
continuations.sort_by(|left, right| left.call_id.cmp(&right.call_id));
state.summary.code_mode_cells_ignored = state.code_mode_call_ids.len();
state.summary = analyze_observations(state.summary, &direct_calls, &continuations).summary;
SlowCommandCollection {
summary: state.summary,
direct_calls,
continuations,
}
}
#[cfg(test)]
#[path = "slow_commands_tests.rs"]
mod tests;
+227
View File
@@ -0,0 +1,227 @@
use super::SlowCommandAggregate;
use super::SlowCommandAnalysis;
use super::SlowCommandCollection;
use super::SlowCommandContinuation;
use super::SlowCommandDirectCall;
use super::SlowCommandSummary;
use super::parsing::first_executable;
use std::collections::HashMap;
use std::collections::HashSet;
use std::time::Duration;
struct Invocation {
command: String,
executable: String,
continuation_wait_count: usize,
total_wait: Duration,
}
/// Merges low-level collections, deduplicates calls globally, and ranks their commands.
pub fn merge_slow_command_collections(
collections: impl IntoIterator<Item = SlowCommandCollection>,
) -> SlowCommandAnalysis {
let mut summary = SlowCommandSummary::default();
let mut direct_calls = HashMap::<String, SlowCommandDirectCall>::new();
let mut continuations = HashMap::<String, SlowCommandContinuation>::new();
for collection in collections {
add_coverage(&mut summary, &collection.summary);
for direct_call in collection.direct_calls {
match direct_calls.entry(direct_call.call_id.clone()) {
std::collections::hash_map::Entry::Occupied(mut entry) => {
summary.duplicate_call_ids = summary.duplicate_call_ids.saturating_add(1);
merge_direct_call(entry.get_mut(), direct_call);
}
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert(direct_call);
}
}
}
for continuation in collection.continuations {
match continuations.entry(continuation.call_id.clone()) {
std::collections::hash_map::Entry::Occupied(mut entry) => {
summary.duplicate_call_ids = summary.duplicate_call_ids.saturating_add(1);
merge_continuation(entry.get_mut(), continuation);
}
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert(continuation);
}
}
}
}
let direct_calls = direct_calls.into_values().collect::<Vec<_>>();
let continuations = continuations.into_values().collect::<Vec<_>>();
analyze_observations(summary, &direct_calls, &continuations)
}
pub(super) fn analyze_observations(
mut summary: SlowCommandSummary,
direct_calls: &[SlowCommandDirectCall],
continuations: &[SlowCommandContinuation],
) -> SlowCommandAnalysis {
let mut continuations_by_invocation = HashMap::<&str, Vec<&SlowCommandContinuation>>::new();
for continuation in continuations {
if let Some(invocation_call_id) = continuation.invocation_call_id.as_deref() {
continuations_by_invocation
.entry(invocation_call_id)
.or_default()
.push(continuation);
}
}
let mut invocations = Vec::new();
let mut attributed_continuation_ids = HashSet::new();
let mut open_processes = 0usize;
for direct_call in direct_calls {
let Some(command) = direct_call.command.as_ref() else {
continue;
};
let mut total_wait = direct_call.wait.unwrap_or(Duration::ZERO);
let mut wait_samples = usize::from(direct_call.wait.is_some());
let mut continuation_wait_count = 0usize;
let mut observed_running = direct_call.observed_running;
let mut observed_terminal = direct_call.observed_terminal;
for continuation in continuations_by_invocation
.get(direct_call.call_id.as_str())
.into_iter()
.flatten()
{
observed_running |= continuation.observed_running;
observed_terminal |= continuation.observed_terminal;
if let Some(wait) = continuation.wait {
total_wait = total_wait.saturating_add(wait);
wait_samples = wait_samples.saturating_add(1);
continuation_wait_count = continuation_wait_count.saturating_add(1);
attributed_continuation_ids.insert(continuation.call_id.as_str());
}
}
if observed_running && !observed_terminal {
open_processes = open_processes.saturating_add(1);
}
if wait_samples > 0 {
invocations.push(Invocation {
executable: first_executable(command),
command: command.clone(),
continuation_wait_count,
total_wait,
});
}
}
summary.direct_shell_calls_seen = direct_calls.len();
summary.direct_shell_calls_analyzed = invocations.len();
summary.direct_shell_calls_skipped = summary
.direct_shell_calls_seen
.saturating_sub(summary.direct_shell_calls_analyzed);
summary.continuation_calls_seen = continuations.len();
summary.continuation_calls_attributed = attributed_continuation_ids.len();
summary.continuation_calls_skipped = summary
.continuation_calls_seen
.saturating_sub(summary.continuation_calls_attributed);
summary.open_processes_at_eof = open_processes;
summary.total_wait = invocations
.iter()
.fold(Duration::ZERO, |total, invocation| {
total.saturating_add(invocation.total_wait)
});
let exact_commands =
aggregate_invocations(&invocations, |invocation| invocation.command.as_str());
let command_families =
aggregate_invocations(&invocations, |invocation| invocation.executable.as_str());
SlowCommandAnalysis {
summary,
exact_commands,
command_families,
}
}
pub(super) fn merge_optional_string(target: &mut Option<String>, incoming: Option<String>) {
if let Some(incoming) = incoming
&& target
.as_ref()
.is_none_or(|current| incoming.as_str() < current.as_str())
{
*target = Some(incoming);
}
}
pub(super) fn merge_optional_duration(target: &mut Option<Duration>, incoming: Option<Duration>) {
if let Some(incoming) = incoming
&& target.is_none_or(|current| incoming > current)
{
*target = Some(incoming);
}
}
fn add_coverage(target: &mut SlowCommandSummary, source: &SlowCommandSummary) {
target.rollout_files_seen = target
.rollout_files_seen
.saturating_add(source.rollout_files_seen);
target.rollout_files_analyzed = target
.rollout_files_analyzed
.saturating_add(source.rollout_files_analyzed);
target.rollout_files_skipped = target
.rollout_files_skipped
.saturating_add(source.rollout_files_skipped);
target.internal_rollout_files_skipped = target
.internal_rollout_files_skipped
.saturating_add(source.internal_rollout_files_skipped);
target.rollout_lines_skipped = target
.rollout_lines_skipped
.saturating_add(source.rollout_lines_skipped);
target.duplicate_call_ids = target
.duplicate_call_ids
.saturating_add(source.duplicate_call_ids);
target.code_mode_cells_ignored = target
.code_mode_cells_ignored
.saturating_add(source.code_mode_cells_ignored);
}
fn merge_direct_call(target: &mut SlowCommandDirectCall, incoming: SlowCommandDirectCall) {
merge_optional_string(&mut target.command, incoming.command);
merge_optional_duration(&mut target.wait, incoming.wait);
target.observed_running |= incoming.observed_running;
target.observed_terminal |= incoming.observed_terminal;
}
fn merge_continuation(target: &mut SlowCommandContinuation, incoming: SlowCommandContinuation) {
merge_optional_string(&mut target.invocation_call_id, incoming.invocation_call_id);
merge_optional_duration(&mut target.wait, incoming.wait);
target.observed_running |= incoming.observed_running;
target.observed_terminal |= incoming.observed_terminal;
}
fn aggregate_invocations<'a>(
invocations: &'a [Invocation],
key: impl Fn(&'a Invocation) -> &'a str,
) -> Vec<SlowCommandAggregate> {
let mut aggregates = HashMap::<String, SlowCommandAggregate>::new();
for invocation in invocations {
let aggregate = aggregates
.entry(key(invocation).to_string())
.or_insert_with(|| SlowCommandAggregate {
key: key(invocation).to_string(),
invocation_count: 0,
continuation_wait_count: 0,
total_wait: Duration::ZERO,
max_wait: Duration::ZERO,
});
aggregate.invocation_count = aggregate.invocation_count.saturating_add(1);
aggregate.continuation_wait_count = aggregate
.continuation_wait_count
.saturating_add(invocation.continuation_wait_count);
aggregate.total_wait = aggregate.total_wait.saturating_add(invocation.total_wait);
aggregate.max_wait = aggregate.max_wait.max(invocation.total_wait);
}
let mut aggregates = aggregates.into_values().collect::<Vec<_>>();
aggregates.sort_by(|left, right| {
right
.total_wait
.cmp(&left.total_wait)
.then_with(|| right.invocation_count.cmp(&left.invocation_count))
.then_with(|| left.key.cmp(&right.key))
});
aggregates
}
@@ -0,0 +1,120 @@
use codex_shell_command::bash::try_parse_shell;
use regex::Regex;
use serde_json::Value;
use std::sync::LazyLock;
use std::time::Duration;
static WALL_TIME_RE: LazyLock<Regex> = LazyLock::new(|| {
compile_regex(r"(?m)^(?:Wall time|Duration): (?<seconds>-?[0-9]+(?:\.[0-9]+)?) seconds\r?$")
});
static RUNNING_SESSION_RE: LazyLock<Regex> = LazyLock::new(|| {
compile_regex(r"(?m)^Process running with session ID (?<session_id>-?[0-9]+)\r?$")
});
fn compile_regex(pattern: &str) -> Regex {
match Regex::new(pattern) {
Ok(regex) => regex,
Err(error) => panic!("invalid slow-command regex `{pattern}`: {error}"),
}
}
pub(super) fn parse_direct_command(name: &str, arguments: &str) -> Option<String> {
let value: Value = serde_json::from_str(arguments).ok()?;
let command = match name {
"shell_command" => value.get("command"),
"exec_command" => value.get("cmd").or_else(|| value.get("command")),
_ => None,
}?;
match command {
Value::String(command) if !command.is_empty() => Some(command.clone()),
Value::Array(parts) => {
let parts = parts
.iter()
.map(Value::as_str)
.collect::<Option<Vec<_>>>()?;
(!parts.is_empty()).then(|| {
codex_shell_command::parse_command::shlex_join(
&parts.into_iter().map(str::to_string).collect::<Vec<_>>(),
)
})
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) | Value::String(_) => {
None
}
}
}
pub(super) fn parse_session_id(arguments: &str) -> Option<i64> {
serde_json::from_str::<Value>(arguments)
.ok()?
.get("session_id")?
.as_i64()
}
pub(super) fn parse_wall_time(output: &str) -> Option<Duration> {
let seconds = WALL_TIME_RE
.captures(output)?
.name("seconds")?
.as_str()
.parse::<f64>()
.ok()?;
Duration::try_from_secs_f64(seconds).ok()
}
pub(super) fn parse_running_session_id(output: &str) -> Option<i64> {
RUNNING_SESSION_RE
.captures(output)?
.name("session_id")?
.as_str()
.parse()
.ok()
}
pub(super) fn first_executable(command: &str) -> String {
let parsed = try_parse_shell(command).and_then(|tree| {
let mut nodes = vec![tree.root_node()];
let mut command_name: Option<(usize, String)> = None;
while let Some(node) = nodes.pop() {
if node.kind() == "command_name"
&& command_name
.as_ref()
.is_none_or(|(start_byte, _name)| node.start_byte() < *start_byte)
&& let Ok(name) = node.utf8_text(command.as_bytes())
{
command_name = Some((node.start_byte(), name.to_string()));
}
let mut cursor = node.walk();
nodes.extend(node.children(&mut cursor));
}
command_name.map(|(_start_byte, name)| name)
});
let executable = parsed.or_else(|| {
command
.split_whitespace()
.find(|token| *token != "&" && !looks_like_environment_assignment(token))
.map(str::to_string)
});
normalize_executable(executable.as_deref().unwrap_or("<unknown>"))
}
fn looks_like_environment_assignment(token: &str) -> bool {
token.split_once('=').is_some_and(|(name, _value)| {
!name.is_empty()
&& name
.chars()
.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
})
}
pub(super) fn normalize_executable(executable: &str) -> String {
let executable = executable.trim_matches(['\'', '"']);
let executable = executable.rsplit(['/', '\\']).next().unwrap_or(executable);
let suffix = executable.get(executable.len().saturating_sub(4)..);
if executable.len() > 4 && suffix.is_some_and(|suffix| suffix.eq_ignore_ascii_case(".exe")) {
executable[..executable.len() - 4].to_string()
} else if executable.is_empty() {
"<unknown>".to_string()
} else {
executable.to_string()
}
}
+398
View File
@@ -0,0 +1,398 @@
use super::*;
use crate::compression::compressed_rollout_path;
use codex_protocol::protocol::InternalSessionSource;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use tempfile::TempDir;
use uuid::Uuid;
struct Fixture {
home: TempDir,
}
impl Fixture {
fn new() -> Self {
Self {
home: TempDir::new().expect("create temp Codex home"),
}
}
fn write_rollout(&self, archived: bool, source: SessionSource, lines: &[String]) -> PathBuf {
let id = Uuid::new_v4();
let root = if archived {
self.home.path().join(ARCHIVED_SESSIONS_SUBDIR)
} else {
self.home.path().join(SESSIONS_SUBDIR).join("2026/06/18")
};
fs::create_dir_all(&root).expect("create rollout directory");
let path = root.join(format!("rollout-2026-06-18T12-00-00-{id}.jsonl"));
let mut file = fs::File::create(&path).expect("create rollout");
writeln!(
file,
"{}",
json!({
"timestamp": "2026-06-18T12:00:00Z",
"type": "session_meta",
"payload": {
"id": id,
"timestamp": "2026-06-18T12:00:00Z",
"cwd": self.home.path(),
"originator": "test",
"cli_version": "test",
"source": source,
"model_provider": null,
},
})
)
.expect("write session metadata");
for line in lines {
writeln!(file, "{line}").expect("write rollout line");
}
path
}
}
fn response_item(payload: Value) -> String {
json!({
"timestamp": "2026-06-18T12:00:01Z",
"type": "response_item",
"payload": payload,
})
.to_string()
}
fn function_call(call_id: &str, name: &str, arguments: Value) -> String {
response_item(json!({
"type": "function_call",
"name": name,
"arguments": arguments.to_string(),
"call_id": call_id,
}))
}
fn function_output(call_id: &str, output: &str) -> String {
response_item(json!({
"type": "function_call_output",
"call_id": call_id,
"output": output,
}))
}
fn direct_command(call_id: &str, command: &str, output: &str) -> Vec<String> {
vec![
function_call(call_id, "exec_command", json!({"cmd": command})),
function_output(call_id, output),
]
}
fn aggregate(
key: &str,
invocation_count: usize,
continuation_wait_count: usize,
total_seconds: u64,
max_seconds: u64,
) -> SlowCommandAggregate {
SlowCommandAggregate {
key: key.to_string(),
invocation_count,
continuation_wait_count,
total_wait: Duration::from_secs(total_seconds),
max_wait: Duration::from_secs(max_seconds),
}
}
#[tokio::test]
async fn analyzes_direct_shell_waits_across_root_and_subagent_rollouts() {
let fixture = Fixture::new();
let mut root_lines = Vec::new();
root_lines.extend(direct_command(
"exec-background",
"cargo test -p codex-core",
"Wall time: 10.0000 seconds\nProcess running with session ID 42\nOutput:",
));
root_lines.push(function_call(
"poll-background",
"write_stdin",
json!({"session_id": 42, "chars": ""}),
));
root_lines.push(function_output(
"poll-background",
"Wall time: 5.0000 seconds\nProcess exited with code 0\nOutput:",
));
root_lines.extend(direct_command(
"exec-check",
"cargo check",
"Wall time: 2.0000 seconds\nProcess exited with code 0\nOutput:",
));
root_lines.extend(direct_command(
"exec-rg",
"/usr/bin/rg TODO",
"Wall time: 3.0000 seconds\nProcess exited with code 0\nOutput:",
));
root_lines.extend(direct_command(
"exec-open",
"sleep 100",
"Wall time: 1.0000 seconds\nProcess running with session ID 77\nOutput:",
));
root_lines.extend(direct_command(
"exec-bad-output",
"echo missing-time",
"Process exited with code 0\nOutput:",
));
root_lines.push(response_item(json!({
"type": "function_call",
"name": "exec_command",
"arguments": "{malformed",
"call_id": "exec-bad-arguments",
})));
root_lines.push(function_output(
"exec-bad-arguments",
"Wall time: 8.0000 seconds\nProcess exited with code 0\nOutput:",
));
root_lines.push(function_call(
"orphan-poll",
"write_stdin",
json!({"session_id": 999, "chars": ""}),
));
root_lines.push(function_output(
"orphan-poll",
"Wall time: 4.0000 seconds\nProcess exited with code 0\nOutput:",
));
root_lines.push(response_item(json!({
"type": "custom_tool_call",
"call_id": "code-cell",
"name": "exec",
"input": "await tools.exec_command({cmd: 'ignored'})",
})));
root_lines.push(response_item(json!({
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "<user_shell_command>ignored</user_shell_command>"}],
})));
root_lines.push("{malformed".to_string());
fixture.write_rollout(/*archived*/ false, SessionSource::Cli, &root_lines);
let subagent_lines = direct_command(
"subagent-test",
"cargo test -p codex-core",
"Duration: 3.0000 seconds\nOutput:",
);
fixture.write_rollout(
/*archived*/ true,
SessionSource::SubAgent(SubAgentSource::Review),
&subagent_lines,
);
let internal_lines = direct_command(
"internal-call",
"cargo test --workspace",
"Wall time: 100.0000 seconds\nProcess exited with code 0\nOutput:",
);
fixture.write_rollout(
/*archived*/ false,
SessionSource::Internal(InternalSessionSource::MemoryConsolidation),
&internal_lines,
);
let analysis = analyze_slow_commands(fixture.home.path()).await;
assert_eq!(
analysis,
SlowCommandAnalysis {
summary: SlowCommandSummary {
rollout_files_seen: 3,
rollout_files_analyzed: 2,
rollout_files_skipped: 0,
internal_rollout_files_skipped: 1,
rollout_lines_skipped: 1,
direct_shell_calls_seen: 7,
direct_shell_calls_analyzed: 5,
direct_shell_calls_skipped: 2,
continuation_calls_seen: 2,
continuation_calls_attributed: 1,
continuation_calls_skipped: 1,
duplicate_call_ids: 0,
open_processes_at_eof: 1,
code_mode_cells_ignored: 1,
total_wait: Duration::from_secs(24),
},
exact_commands: vec![
aggregate("cargo test -p codex-core", 2, 1, 18, 15),
aggregate("/usr/bin/rg TODO", 1, 0, 3, 3),
aggregate("cargo check", 1, 0, 2, 2),
aggregate("sleep 100", 1, 0, 1, 1),
],
command_families: vec![
aggregate("cargo", 3, 1, 20, 15),
aggregate("rg", 1, 0, 3, 3),
aggregate("sleep", 1, 0, 1, 1),
],
}
);
}
#[tokio::test]
async fn sums_full_waits_for_overlapping_background_processes() {
let fixture = Fixture::new();
let mut lines = Vec::new();
lines.extend(direct_command(
"first",
"cargo test first",
"Wall time: 10.0000 seconds\nProcess running with session ID 1\nOutput:",
));
lines.extend(direct_command(
"second",
"cargo test second",
"Wall time: 7.0000 seconds\nProcess running with session ID 2\nOutput:",
));
for (call_id, session_id, seconds) in [("poll-first", 1, 5), ("poll-second", 2, 4)] {
lines.push(function_call(
call_id,
"write_stdin",
json!({"session_id": session_id, "chars": ""}),
));
lines.push(function_output(
call_id,
&format!("Wall time: {seconds}.0000 seconds\nProcess exited with code 0\nOutput:"),
));
}
fixture.write_rollout(/*archived*/ false, SessionSource::Cli, &lines);
let analysis = analyze_slow_commands(fixture.home.path()).await;
assert_eq!(analysis.summary.total_wait, Duration::from_secs(26));
assert_eq!(
analysis.command_families,
vec![aggregate("cargo", 2, 2, 26, 15)]
);
}
#[tokio::test]
async fn merges_overlapping_collections_by_direct_and_continuation_call_id() {
let first_fixture = Fixture::new();
let second_fixture = Fixture::new();
let mut shared_lines = direct_command(
"shared-direct",
"cargo test merge",
"Wall time: 10.0000 seconds\nProcess running with session ID 42\nOutput:",
);
shared_lines.push(function_call(
"shared-poll",
"write_stdin",
json!({"session_id": 42, "chars": ""}),
));
shared_lines.push(function_output(
"shared-poll",
"Wall time: 5.0000 seconds\nProcess running with session ID 42\nOutput:",
));
first_fixture.write_rollout(/*archived*/ false, SessionSource::Cli, &shared_lines);
let mut completed_lines = shared_lines;
completed_lines.push(function_call(
"terminal-poll",
"write_stdin",
json!({"session_id": 42, "chars": ""}),
));
completed_lines.push(function_output(
"terminal-poll",
"Wall time: 7.0000 seconds\nProcess exited with code 0\nOutput:",
));
second_fixture.write_rollout(
/*archived*/ false,
SessionSource::Cli,
&completed_lines,
);
let first = collect_slow_commands(first_fixture.home.path()).await;
let second = collect_slow_commands(second_fixture.home.path()).await;
let analysis = merge_slow_command_collections([first, second]);
assert_eq!(
analysis,
SlowCommandAnalysis {
summary: SlowCommandSummary {
rollout_files_seen: 2,
rollout_files_analyzed: 2,
direct_shell_calls_seen: 1,
direct_shell_calls_analyzed: 1,
continuation_calls_seen: 2,
continuation_calls_attributed: 2,
duplicate_call_ids: 2,
total_wait: Duration::from_secs(22),
..Default::default()
},
exact_commands: vec![aggregate("cargo test merge", 1, 2, 22, 22)],
command_families: vec![aggregate("cargo", 1, 2, 22, 22)],
}
);
}
#[tokio::test]
async fn reads_compressed_history_and_deduplicates_copied_calls_and_siblings() {
let fixture = Fixture::new();
let lines = direct_command(
"copied-call",
"just test -p codex-rollout",
"Wall time: 4.0000 seconds\nProcess exited with code 0\nOutput:",
);
let active_path = fixture.write_rollout(/*archived*/ false, SessionSource::Cli, &lines);
let active_contents = fs::read(&active_path).expect("read active rollout");
fs::write(
compressed_rollout_path(&active_path),
zstd::stream::encode_all(active_contents.as_slice(), 0).expect("compress active rollout"),
)
.expect("write compressed sibling");
let archived_path = fixture.write_rollout(/*archived*/ true, SessionSource::Cli, &lines);
let archived_contents = fs::read(&archived_path).expect("read archived rollout");
let archived_compressed = compressed_rollout_path(&archived_path);
fs::write(
&archived_compressed,
zstd::stream::encode_all(archived_contents.as_slice(), 0)
.expect("compress archived rollout"),
)
.expect("write compressed archived rollout");
fs::remove_file(archived_path).expect("remove plain archived rollout");
let analysis = analyze_slow_commands(fixture.home.path()).await;
assert_eq!(analysis.summary.rollout_files_seen, 2);
assert_eq!(analysis.summary.rollout_files_analyzed, 2);
assert_eq!(analysis.summary.duplicate_call_ids, 1);
assert_eq!(analysis.summary.direct_shell_calls_seen, 1);
assert_eq!(analysis.summary.direct_shell_calls_analyzed, 1);
assert_eq!(analysis.summary.total_wait, Duration::from_secs(4));
assert_eq!(
analysis.exact_commands,
vec![aggregate("just test -p codex-rollout", 1, 0, 4, 4)]
);
}
#[tokio::test]
async fn empty_home_returns_an_empty_analysis() {
let fixture = Fixture::new();
assert_eq!(
analyze_slow_commands(fixture.home.path()).await,
SlowCommandAnalysis::default()
);
}
#[test]
fn executable_family_uses_shell_parsing_and_cross_platform_path_normalization() {
assert_eq!(
parsing::first_executable("FOO=bar /usr/local/bin/cargo test"),
"cargo"
);
assert_eq!(
parsing::normalize_executable(r"C:\Tools\cargo.EXE"),
"cargo"
);
assert_eq!(parsing::first_executable(""), "<unknown>");
}