Agent jobs (spawn_agents_on_csv) + progress UI (#10935)

## Summary
- Add agent job support: spawn a batch of sub-agents from CSV, auto-run,
auto-export, and store results in SQLite.
- Simplify workflow: remove run/resume/get-status/export tools; spawn is
deterministic and completes in one call.
- Improve exec UX: stable, single-line progress bar with ETA; suppress
sub-agent chatter in exec.

## Why
Enables map-reduce style workflows over arbitrarily large repos using
the existing Codex orchestrator. This addresses review feedback about
overly complex job controls and non-deterministic monitoring.

## Demo (progress bar)
```
./codex-rs/target/debug/codex exec \
  --enable collab \
  --enable sqlite \
  --full-auto \
  --progress-cursor \
  -c agents.max_threads=16 \
  -C /Users/daveaitel/code/codex \
  - <<'PROMPT'
Create /tmp/agent_job_progress_demo.csv with columns: path,area and 30 rows:
path = item-01..item-30, area = test.

Then call spawn_agents_on_csv with:
- csv_path: /tmp/agent_job_progress_demo.csv
- instruction: "Run `python - <<'PY'` to sleep a random 0.3–1.2s, then output JSON with keys: path, score (int). Set score = 1."
- output_csv_path: /tmp/agent_job_progress_demo_out.csv
PROMPT
```

## Review feedback addressed
- Auto-start jobs on spawn; removed run/resume/status/export tools.
- Auto-export on success.
- More descriptive tool spec + clearer prompts.
- Avoid deadlocks on spawn failure; pending/running handled safely.
- Progress bar no longer scrolls; stable single-line redraw.

## Tests
- `cd codex-rs && cargo test -p codex-exec`
- `cd codex-rs && cargo build -p codex-cli`
This commit is contained in:
daveaitel-openai
2026-02-24 16:00:19 -05:00
committed by GitHub
parent bd192b54cd
commit dcab40123f
36 changed files with 3370 additions and 50 deletions

View File

@@ -38,9 +38,12 @@ use codex_utils_elapsed::format_duration;
use codex_utils_elapsed::format_elapsed;
use owo_colors::OwoColorize;
use owo_colors::Style;
use serde::Deserialize;
use shlex::try_join;
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use crate::event_processor::CodexStatus;
@@ -76,11 +79,17 @@ pub(crate) struct EventProcessorWithHumanOutput {
last_total_token_usage: Option<codex_protocol::protocol::TokenUsageInfo>,
final_message: Option<String>,
last_proposed_plan: Option<String>,
progress_active: bool,
progress_last_len: usize,
use_ansi_cursor: bool,
progress_anchor: bool,
progress_done: bool,
}
impl EventProcessorWithHumanOutput {
pub(crate) fn create_with_ansi(
with_ansi: bool,
cursor_ansi: bool,
config: &Config,
last_message_path: Option<PathBuf>,
) -> Self {
@@ -103,6 +112,11 @@ impl EventProcessorWithHumanOutput {
last_total_token_usage: None,
final_message: None,
last_proposed_plan: None,
progress_active: false,
progress_last_len: 0,
use_ansi_cursor: cursor_ansi,
progress_anchor: false,
progress_done: false,
}
} else {
Self {
@@ -121,11 +135,27 @@ impl EventProcessorWithHumanOutput {
last_total_token_usage: None,
final_message: None,
last_proposed_plan: None,
progress_active: false,
progress_last_len: 0,
use_ansi_cursor: cursor_ansi,
progress_anchor: false,
progress_done: false,
}
}
}
}
#[derive(Debug, Deserialize)]
struct AgentJobProgressMessage {
job_id: String,
total_items: usize,
pending_items: usize,
running_items: usize,
completed_items: usize,
failed_items: usize,
eta_seconds: Option<u64>,
}
struct PatchApplyBegin {
start_time: Instant,
auto_approved: bool,
@@ -176,6 +206,18 @@ impl EventProcessor for EventProcessorWithHumanOutput {
fn process_event(&mut self, event: Event) -> CodexStatus {
let Event { id: _, msg } = event;
if let EventMsg::BackgroundEvent(BackgroundEventEvent { message }) = &msg
&& let Some(update) = Self::parse_agent_job_progress(message)
{
self.render_agent_job_progress(update);
return CodexStatus::Running;
}
if self.progress_active && !Self::should_interrupt_progress(&msg) {
return CodexStatus::Running;
}
if !Self::is_silent_event(&msg) {
self.finish_progress_line();
}
match msg {
EventMsg::Error(ErrorEvent { message, .. }) => {
let prefix = "ERROR:".style(self.red);
@@ -818,6 +860,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
fn print_final_output(&mut self) {
self.finish_progress_line();
if let Some(usage_info) = &self.last_total_token_usage {
eprintln!(
"{}\n{}",
@@ -841,6 +884,207 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
}
impl EventProcessorWithHumanOutput {
fn parse_agent_job_progress(message: &str) -> Option<AgentJobProgressMessage> {
let payload = message.strip_prefix("agent_job_progress:")?;
serde_json::from_str::<AgentJobProgressMessage>(payload).ok()
}
fn is_silent_event(msg: &EventMsg) -> bool {
matches!(
msg,
EventMsg::ThreadNameUpdated(_)
| EventMsg::TokenCount(_)
| EventMsg::TurnStarted(_)
| EventMsg::ExecApprovalRequest(_)
| EventMsg::ApplyPatchApprovalRequest(_)
| EventMsg::TerminalInteraction(_)
| EventMsg::ExecCommandOutputDelta(_)
| EventMsg::GetHistoryEntryResponse(_)
| EventMsg::McpListToolsResponse(_)
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::ListRemoteSkillsResponse(_)
| EventMsg::RemoteSkillDownloaded(_)
| EventMsg::RawResponseItem(_)
| EventMsg::UserMessage(_)
| EventMsg::EnteredReviewMode(_)
| EventMsg::ExitedReviewMode(_)
| EventMsg::AgentMessageDelta(_)
| EventMsg::AgentReasoningDelta(_)
| EventMsg::AgentReasoningRawContentDelta(_)
| EventMsg::ItemStarted(_)
| EventMsg::ItemCompleted(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::PlanDelta(_)
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_)
| EventMsg::SkillsUpdateAvailable
| EventMsg::UndoCompleted(_)
| EventMsg::UndoStarted(_)
| EventMsg::ThreadRolledBack(_)
| EventMsg::RequestUserInput(_)
| EventMsg::DynamicToolCallRequest(_)
)
}
fn should_interrupt_progress(msg: &EventMsg) -> bool {
matches!(
msg,
EventMsg::Error(_)
| EventMsg::Warning(_)
| EventMsg::DeprecationNotice(_)
| EventMsg::StreamError(_)
| EventMsg::TurnComplete(_)
| EventMsg::ShutdownComplete
)
}
fn finish_progress_line(&mut self) {
if self.progress_active {
self.progress_active = false;
self.progress_last_len = 0;
self.progress_done = false;
if self.use_ansi_cursor {
if self.progress_anchor {
eprintln!("\u{1b}[1A\u{1b}[1G\u{1b}[2K");
} else {
eprintln!("\u{1b}[1G\u{1b}[2K");
}
} else {
eprintln!();
}
self.progress_anchor = false;
}
}
fn render_agent_job_progress(&mut self, update: AgentJobProgressMessage) {
let total = update.total_items.max(1);
let processed = update.completed_items + update.failed_items;
let percent = (processed as f64 / total as f64 * 100.0).round() as i64;
let job_label = update.job_id.chars().take(8).collect::<String>();
let eta = update
.eta_seconds
.map(|secs| format_duration(Duration::from_secs(secs)))
.unwrap_or_else(|| "--".to_string());
let columns = std::env::var("COLUMNS")
.ok()
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| *value > 0);
let line = format_agent_job_progress_line(
columns,
job_label.as_str(),
AgentJobProgressStats {
processed,
total,
percent,
failed: update.failed_items,
running: update.running_items,
pending: update.pending_items,
},
eta.as_str(),
);
let done = processed >= update.total_items;
if !self.use_ansi_cursor {
eprintln!("{line}");
if done {
self.progress_active = false;
self.progress_last_len = 0;
}
return;
}
if done && self.progress_done {
return;
}
if !self.progress_active {
eprintln!();
self.progress_anchor = true;
self.progress_done = false;
}
let mut output = String::new();
if self.progress_anchor {
output.push_str("\u{1b}[1A\u{1b}[1G\u{1b}[2K");
} else {
output.push_str("\u{1b}[1G\u{1b}[2K");
}
output.push_str(&line);
if done {
output.push('\n');
eprint!("{output}");
self.progress_active = false;
self.progress_last_len = 0;
self.progress_anchor = false;
self.progress_done = true;
return;
}
eprint!("{output}");
let _ = std::io::stderr().flush();
self.progress_active = true;
self.progress_last_len = line.len();
}
}
struct AgentJobProgressStats {
processed: usize,
total: usize,
percent: i64,
failed: usize,
running: usize,
pending: usize,
}
fn format_agent_job_progress_line(
columns: Option<usize>,
job_label: &str,
stats: AgentJobProgressStats,
eta: &str,
) -> String {
let rest = format!(
"{processed}/{total} {percent}% f{failed} r{running} p{pending} eta {eta}",
processed = stats.processed,
total = stats.total,
percent = stats.percent,
failed = stats.failed,
running = stats.running,
pending = stats.pending
);
let prefix = format!("job {job_label}");
let base_len = prefix.len() + rest.len() + 4;
let mut bar_width = columns
.and_then(|columns| columns.checked_sub(base_len))
.filter(|available| *available > 0)
.unwrap_or(20usize);
let with_bar = |width: usize| {
let filled = ((stats.processed as f64 / stats.total as f64) * width as f64)
.round()
.clamp(0.0, width as f64) as usize;
let mut bar = "#".repeat(filled);
bar.push_str(&"-".repeat(width - filled));
format!("{prefix} [{bar}] {rest}")
};
let mut line = with_bar(bar_width);
if let Some(columns) = columns
&& line.len() > columns
{
let min_line = format!("{prefix} {rest}");
if min_line.len() > columns {
let mut truncated = min_line;
if columns > 2 && truncated.len() > columns {
truncated.truncate(columns - 2);
truncated.push_str("..");
}
return truncated;
}
let available = columns.saturating_sub(base_len);
if available == 0 {
return min_line;
}
bar_width = available.min(bar_width).max(1);
line = with_bar(bar_width);
}
line
}
fn escape_command(command: &[String]) -> String {
try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" "))
}