fno_agents/codex_ask.rs
1//! Client-side `codex exec` ask path (ab-0429c6e1).
2//!
3//! `codex` is a one-shot `codex exec --json` subprocess (NOT a PTY agent):
4//! it emits a JSONL stream to stdout, the Rust client drains and parses it,
5//! and returns the reply text. The fno daemon cannot handle this path because
6//! `handle_ask` renders a PTY screen; byte-parity with Python's
7//! `providers/codex.py` requires a direct subprocess approach.
8//!
9//! **Byte-parity is the contract.** The observable behavior (stdout reply,
10//! exit code, events.jsonl fields) must match Python's implementation.
11//!
12//! # Architecture
13//!
14//! - **Wave B1** (this file, pure core): argv builders, `inject_from_name`,
15//! JSONL line parser, error enum + exit-code map. No I/O.
16//! - **Wave B2** (this file, subprocess driver): `run_codex` subprocess with
17//! own-pgrp, watchdog SIGTERM->SIGKILL, grace reap, output.jsonl tee;
18//! `codex_create` / `codex_resume`; `dispatch_codex_ask` orchestrator;
19//! `maybe_run_codex_ask` client entry point.
20//!
21//! # Locked Decisions (from Python codex.py)
22//!
23//! - bounded default (create): global `--ask-for-approval never` before `exec`
24//! plus the `exec` flag `--sandbox workspace-write` after it. (`-a` is a
25//! top-level codex flag in >= 0.133.0, not an `exec` flag.)
26//! - full yolo (explicit): `--dangerously-bypass-approvals-and-sandbox`.
27//! - LD7: `inject_from_name` is plain `[from: X]\n\n<prompt>`, no escaping.
28//! - LD8: `output.jsonl` is append-only, line-buffered tee (create & resume).
29//! - LD11: stdin=DEVNULL.
30//! - LD12: stderr merged into stdout (stderr=STDOUT).
31//! - LD14 warn-on-drift: NoSessionId carries observed event type names.
32
33use std::io::{BufRead, BufReader, Write};
34use std::os::unix::process::CommandExt;
35use std::path::{Path, PathBuf};
36use std::time::{Duration, Instant};
37
38use crate::claude_ask::emit_event;
39use crate::paths::AgentsHome;
40use crate::provider::normalize_codex_command;
41use crate::state::{load_registry, update_registry};
42use crate::AgentStatus;
43
44// ===========================================================================
45// Constants (pinned from codex 0.130.0 JSONL capture)
46// ===========================================================================
47
48/// `thread.started` — carries `thread_id` (UUID), used to capture session_id.
49const EV_SESSION: &str = "thread.started";
50/// `turn.completed` — end of turn; break the read loop.
51const EV_COMPLETE: &str = "turn.completed";
52/// `item.completed` — envelope; discriminated by `item.type`.
53const EV_ITEM: &str = "item.completed";
54/// `agent_message` item type — carries `item.text` (assistant reply).
55const ITEM_MESSAGE: &str = "agent_message";
56/// `error` item type — carries `item.message` (soft error text).
57const ITEM_ERROR: &str = "error";
58
59/// Lock-acquisition ceiling (mirrors claude_ask.rs `LOCK_ACQUIRE_TIMEOUT`).
60const LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(30);
61
62/// Default followup timeout (mirrors Python dispatch.py's timeout default).
63const DEFAULT_FOLLOWUP_TIMEOUT: Duration = Duration::from_secs(600);
64
65// ===========================================================================
66// Pure-fn helpers (Wave B1: no I/O, fully unit-testable)
67// ===========================================================================
68
69/// Prepend `[from: <from_name>]\n\n` to `prompt` (Locked Decision 7).
70/// Plain concatenation; no escaping of any kind.
71pub fn inject_from_name(prompt: &str, from_name: &str) -> String {
72 format!("[from: {}]\n\n{}", from_name, prompt)
73}
74
75/// Argv tokens for the create-path sandbox posture (bounded-posture amendment).
76/// - bounded (default): `["--sandbox", "workspace-write"]` - workspace sandbox.
77/// - full yolo (explicit): `["--dangerously-bypass-approvals-and-sandbox"]` -
78/// unsandboxed bypass. The two are mutually exclusive; never combine them.
79///
80/// `--sandbox` is an `exec`-subcommand flag, so these tokens go AFTER `exec`.
81/// The approval policy is a SEPARATE global flag, see [`approval_flag`].
82/// Mirror of `codex.py::sandbox_flag`.
83pub fn sandbox_flag(yolo: bool) -> Vec<String> {
84 if yolo {
85 vec!["--dangerously-bypass-approvals-and-sandbox".to_string()]
86 } else {
87 vec!["--sandbox".to_string(), "workspace-write".to_string()]
88 }
89}
90
91/// Argv tokens for the create-path approval policy (bounded-posture amendment).
92/// - bounded (default): `["--ask-for-approval", "never"]` - never prompt (no
93/// hang); a blocked action is returned to the model rather than waiting.
94/// - full yolo: `[]` - `--dangerously-bypass-approvals-and-sandbox` (emitted by
95/// [`sandbox_flag`]) already disables approval, so this stays empty.
96///
97/// CRITICAL: in codex >= 0.133.0 `-a/--ask-for-approval` is a GLOBAL flag on the
98/// top-level `codex` command, NOT a flag on the `exec` subcommand. It must be
99/// emitted BEFORE `exec` in the argv; placing it after `exec` makes clap reject
100/// it with `error: unexpected argument '--ask-for-approval' found`, which aborts
101/// the spawn before any session id is emitted. Mirror of `codex.py::approval_flag`.
102pub fn approval_flag(yolo: bool) -> Vec<String> {
103 if yolo {
104 vec![]
105 } else {
106 vec!["--ask-for-approval".to_string(), "never".to_string()]
107 }
108}
109
110/// Argv tokens for the sandbox mode on the resume path.
111/// `codex exec resume` only accepts the bypass flag; `--sandbox` is not
112/// honored on resume (verified against codex 0.130.0 --help).
113/// - default: `[]` (inherits original session sandbox)
114/// - yolo: `["--dangerously-bypass-approvals-and-sandbox"]`
115pub fn sandbox_flag_resume(yolo: bool) -> Vec<String> {
116 if yolo {
117 vec!["--dangerously-bypass-approvals-and-sandbox".to_string()]
118 } else {
119 vec![]
120 }
121}
122
123/// Build the create argv: `codex exec --json -C <cwd> --skip-git-repo-check <sandbox> <full_prompt>`.
124/// `full_prompt` should already have been built via `inject_from_name`.
125/// The subprocess cwd is NOT set via Popen(cwd=...) on the create path
126/// (the `-C` flag handles it); Python codex.create passes `popen_cwd=None`.
127pub fn build_argv_create(
128 cwd: &Path,
129 full_prompt: &str,
130 yolo: bool,
131 model: Option<&str>,
132 reasoning_effort: Option<&str>,
133 add_dir: Option<&str>,
134) -> Vec<String> {
135 // Approval is a GLOBAL flag and must precede `exec`; sandbox is an `exec`
136 // flag and follows it. See `approval_flag` / `sandbox_flag`.
137 let mut argv = vec!["codex".to_string()];
138 argv.extend(approval_flag(yolo));
139 argv.extend([
140 "exec".to_string(),
141 "--json".to_string(),
142 "-C".to_string(),
143 cwd.to_string_lossy().to_string(),
144 "--skip-git-repo-check".to_string(),
145 ]);
146 // x-b6e2: a user `--add-dir` grants extra write access on `codex exec`.
147 // codex's own cwd rides `-C` (separate flag), so this is purely additive -
148 // no collision. Empty/None = unchanged argv.
149 if let Some(d) = add_dir.filter(|d| !d.is_empty()) {
150 argv.push("--add-dir".to_string());
151 argv.push(d.to_string());
152 }
153 // x-c772: an explicit --model is forwarded to `codex exec --model <m>`
154 // (empty/None = codex default). Exact passthrough, no fuzzy resolution.
155 if let Some(m) = model.filter(|m| !m.is_empty()) {
156 argv.push("--model".to_string());
157 argv.push(m.to_string());
158 }
159 if let Some(effort) = reasoning_effort.filter(|e| !e.is_empty()) {
160 argv.push("-c".to_string());
161 argv.push(format!("model_reasoning_effort={effort}"));
162 }
163 argv.extend(sandbox_flag(yolo));
164 argv.push(full_prompt.to_string());
165 argv
166}
167
168/// Build the resume argv: `codex exec resume <session_id> --json --skip-git-repo-check <sandbox_resume> <full_prompt>`.
169/// `full_prompt` should already have been built via `inject_from_name`.
170/// Resume does NOT use `-C`; the subprocess cwd is pinned via `Command::current_dir`.
171pub fn build_argv_resume(session_id: &str, full_prompt: &str, yolo: bool) -> Vec<String> {
172 let mut argv = vec![
173 "codex".to_string(),
174 "exec".to_string(),
175 "resume".to_string(),
176 session_id.to_string(),
177 "--json".to_string(),
178 "--skip-git-repo-check".to_string(),
179 ];
180 argv.extend(sandbox_flag_resume(yolo));
181 argv.push(full_prompt.to_string());
182 argv
183}
184
185// ===========================================================================
186// JSONL event types (parsed from the codex JSONL stream)
187// ===========================================================================
188
189/// Parsed variant of one codex JSONL line.
190#[derive(Debug)]
191pub enum JsonlEvent {
192 /// `thread.started` with a valid `thread_id`.
193 ThreadStarted { thread_id: String },
194 /// `item.completed` where `item.type == "agent_message"`.
195 AgentMessage { text: String },
196 /// `item.completed` where `item.type == "error"`.
197 SoftError { message: String },
198 /// `turn.completed` — end of turn.
199 TurnCompleted,
200 /// Any other JSON object (unknown event type or malformed known type).
201 Other { type_name: Option<String> },
202}
203
204/// Parse one raw line from the codex JSONL stream.
205///
206/// Returns `None` for non-JSON lines (banners, Rust panics on merged stderr,
207/// empty lines) — these are tee'd but not control-flow relevant.
208/// Returns `Some(JsonlEvent::Other)` for valid JSON objects whose event type
209/// is not in the pinned vocabulary, or for malformed known-type events.
210pub fn parse_jsonl_line(line: &str) -> Option<JsonlEvent> {
211 let line = line.trim_end_matches('\n');
212 if line.is_empty() || !line.starts_with('{') {
213 return None;
214 }
215 let v: serde_json::Value = serde_json::from_str(line).ok()?;
216 if !v.is_object() {
217 return None;
218 }
219 let ev_type = v.get("type").and_then(|t| t.as_str());
220 match ev_type {
221 Some(t) if t == EV_SESSION => {
222 let thread_id = v.get("thread_id").and_then(|x| x.as_str())?;
223 Some(JsonlEvent::ThreadStarted {
224 thread_id: thread_id.to_string(),
225 })
226 }
227 Some(t) if t == EV_COMPLETE => Some(JsonlEvent::TurnCompleted),
228 Some(t) if t == EV_ITEM => {
229 let item = v.get("item").and_then(|x| x.as_object());
230 match item {
231 Some(item) => {
232 let item_type = item.get("type").and_then(|x| x.as_str());
233 match item_type {
234 Some(t) if t == ITEM_MESSAGE => {
235 let text = item.get("text").and_then(|x| x.as_str()).unwrap_or("");
236 Some(JsonlEvent::AgentMessage {
237 text: text.to_string(),
238 })
239 }
240 Some(t) if t == ITEM_ERROR => {
241 let msg = item.get("message").and_then(|x| x.as_str()).unwrap_or("");
242 Some(JsonlEvent::SoftError {
243 message: msg.to_string(),
244 })
245 }
246 _ => Some(JsonlEvent::Other {
247 type_name: ev_type.map(String::from),
248 }),
249 }
250 }
251 None => Some(JsonlEvent::Other {
252 type_name: ev_type.map(String::from),
253 }),
254 }
255 }
256 Some(t) => Some(JsonlEvent::Other {
257 type_name: Some(t.to_string()),
258 }),
259 None => Some(JsonlEvent::Other { type_name: None }),
260 }
261}
262
263// ===========================================================================
264// Error enum + exit-code map
265// ===========================================================================
266
267/// Errors from the codex ask path. Each variant maps to a specific Python-
268/// compatible exit code.
269#[derive(Debug)]
270pub enum CodexAskError {
271 /// codex binary not found (FileNotFoundError) — exit 127.
272 NotFound,
273 /// JSONL stream ended without a `thread.started` event — exit 11.
274 /// `types_seen` carries the observed event types for forensics (LD14).
275 NoSessionId { types_seen: Vec<String> },
276 /// Cannot open the output.jsonl tee (EACCES/ENOSPC/etc.) — exit 12.
277 TeeOpen { message: String },
278 /// Wall-clock timeout — exit 15.
279 Timeout { timeout_sec: f64 },
280 /// codex exited non-zero and no reply was captured — exit = exit_code.
281 /// Also used for other OSError at spawn time — exit 1.
282 Invocation { exit_code: i32, message: String },
283 /// SIGKILL escalation during reap — always exit 1 regardless of exit_code.
284 /// A partial reply + SIGKILL is never a success (Python silent-failure-hunter row 4).
285 SigkillEscalated { partial_exit_code: i32 },
286 /// Non-transient OSError at Popen time (not NotFound) — exit 1.
287 OsError { message: String },
288 /// Operator SIGINT (Ctrl-C) forwarded to the codex group — exit 130.
289 /// Mirrors Python's KeyboardInterrupt -> CPython exit 130 (ab-e7fdbcb6).
290 Interrupted,
291}
292
293impl CodexAskError {
294 /// Python-compatible exit code for this error.
295 ///
296 /// `NotFound` maps to 14 ("provider unavailable") directly here, mirroring
297 /// Python's `dispatch.py` mapping of `CodexInvocationError(127)` ->
298 /// `dispatch_create`'s `select_provider` -> 14. Centralizing the remap
299 /// on the error type means both dispatch paths (create and resume) see
300 /// the same final exit code (sigma-review type-design HIGH: previously
301 /// only `dispatch_create` carried the inline `if exit_code == 127 { 14 }`
302 /// remap; `dispatch_resume` would have leaked 127 for the same error).
303 /// `SigkillEscalated` carries the partial exit code so a SIGKILL'd codex
304 /// that exited with codex-reported non-zero before the signal preserves
305 /// that code (matches codex.py:524 silent-failure-hunter row 4).
306 pub fn exit_code(&self) -> i32 {
307 match self {
308 CodexAskError::NotFound => 14,
309 CodexAskError::NoSessionId { .. } => 11,
310 CodexAskError::TeeOpen { .. } => 12,
311 CodexAskError::Timeout { .. } => 15,
312 CodexAskError::Invocation { exit_code, .. } => {
313 if *exit_code != 0 {
314 *exit_code
315 } else {
316 1
317 }
318 }
319 CodexAskError::SigkillEscalated { partial_exit_code } => {
320 if *partial_exit_code != 0 {
321 *partial_exit_code
322 } else {
323 1
324 }
325 }
326 CodexAskError::OsError { .. } => 1,
327 CodexAskError::Interrupted => 130,
328 }
329 }
330}
331
332impl std::fmt::Display for CodexAskError {
333 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334 match self {
335 CodexAskError::NotFound => write!(f, "codex binary not found on PATH"),
336 CodexAskError::NoSessionId { types_seen } => write!(
337 f,
338 "codex did not emit session id; saw events: {:?}; expected one of: [\"thread.started\"]",
339 types_seen
340 ),
341 CodexAskError::TeeOpen { message } => {
342 write!(f, "codex provider: cannot open output tee: {}", message)
343 }
344 CodexAskError::Timeout { timeout_sec } => {
345 write!(f, "codex timed out after {}s", timeout_sec)
346 }
347 CodexAskError::Invocation { exit_code, message } => {
348 write!(f, "codex exited {} ({})", exit_code, message)
349 }
350 CodexAskError::SigkillEscalated { partial_exit_code } => write!(
351 f,
352 "codex was SIGKILL'd during reap (exit {}); partial reply discarded",
353 partial_exit_code
354 ),
355 CodexAskError::OsError { message } => {
356 write!(f, "codex provider: OSError invoking codex: {}", message)
357 }
358 CodexAskError::Interrupted => {
359 write!(f, "codex interrupted by SIGINT (Ctrl-C)")
360 }
361 }
362 }
363}
364
365impl std::error::Error for CodexAskError {}
366
367// ===========================================================================
368// Subprocess driver (Wave B2)
369// ===========================================================================
370
371/// Result of a successful codex create or resume invocation.
372#[derive(Debug, Clone)]
373pub struct CodexResult {
374 /// Exit code (0 on happy path).
375 pub exit_code: i32,
376 /// Session UUID captured from `thread.started` (None on resume).
377 pub session_id: Option<String>,
378 /// Last assistant text (`agent_message`) or last soft-error text if no
379 /// agent_message was emitted (Python silent-failure-hunter row 5 parity).
380 pub last_msg: String,
381 /// Wall-clock elapsed ms.
382 pub duration_ms: u64,
383}
384
385/// Open the JSONL tee in append mode, creating parent dirs. Delegates to the
386/// shared `subprocess_ask::open_tee` and tags the error as codex's `TeeOpen`.
387fn open_tee(log_path: &Path) -> Result<std::fs::File, CodexAskError> {
388 crate::subprocess_ask::open_tee(log_path).map_err(|e| CodexAskError::TeeOpen {
389 message: e.to_string(),
390 })
391}
392
393// SIGINT forwarding (ab-e7fdbcb6 / cv-cfdb7a56) now lives in the shared
394// `subprocess_ask` module so codex and gemini share one implementation. See
395// `crate::subprocess_ask::{SigintForwarder, ask_interrupted}`.
396
397/// Shared subprocess driver for create and resume.
398///
399/// - stdin=DEVNULL (LD11)
400/// - stderr merged into stdout (LD12)
401/// - child in its own process group (`process::Command::process_group(0)`)
402/// - wall-clock watchdog: SIGTERM to pgrp on timeout, SIGKILL after 2s grace
403/// - grace reap after read loop: SIGTERM, then SIGKILL after 5s
404/// - SIGKILL escalation is always a failure (silent-failure-hunter row 4)
405fn run_codex(
406 argv: &[String],
407 output_path: &Path,
408 timeout: Option<Duration>,
409 expect_session: bool,
410 popen_cwd: Option<&Path>,
411 agent_self: Option<&str>,
412) -> Result<CodexResult, CodexAskError> {
413 use std::process::{Command, Stdio};
414
415 let started = Instant::now();
416
417 // Open the tee BEFORE Popen so path/permission errors surface as
418 // structured CodexAskError::TeeOpen rather than a raw panic.
419 let tee_fh = open_tee(output_path)?;
420
421 // QoS (x-c5cc): every codex child is an fno-spawned worker process —
422 // exec-wrap at background priority (identity when worker_qos=off).
423 let argv =
424 crate::spawn_gate::qos_wrap(popen_cwd.unwrap_or_else(|| Path::new(".")), argv.to_vec());
425 let mut cmd = Command::new(&argv[0]);
426 cmd.args(&argv[1..]);
427 cmd.stdin(Stdio::null()); // LD11
428 cmd.stdout(Stdio::piped());
429 cmd.stderr(Stdio::piped()); // merged below via thread
430
431 if let Some(cwd) = popen_cwd {
432 cmd.current_dir(cwd);
433 }
434
435 // Set agent env vars when we know who we are (create path with agent_self).
436 if let Some(name) = agent_self {
437 cmd.env("FNO_AGENT_SELF", name);
438 cmd.env("FNO_AGENT_PROVIDER", "codex");
439 }
440
441 // Put the child in its own process group so SIGTERM/SIGKILL propagate
442 // to codex's subshells (sandbox tooling). Python uses start_new_session=True.
443 unsafe {
444 cmd.pre_exec(|| {
445 // setpgid(0, 0): put this process in a new process group.
446 libc::setpgid(0, 0);
447 Ok(())
448 });
449 }
450
451 let mut child = match cmd.spawn() {
452 Ok(c) => c,
453 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
454 return Err(CodexAskError::NotFound);
455 }
456 Err(e) => {
457 return Err(CodexAskError::OsError {
458 message: e.to_string(),
459 });
460 }
461 };
462
463 let pid = child.id();
464
465 // Forward operator Ctrl-C to the codex process group for the lifetime of
466 // this call (ab-e7fdbcb6). Dropped at function end, after the child is
467 // reaped, which restores the prior SIGINT disposition. codex is
468 // `setpgid(0, 0)`, so its pgid equals its pid.
469 let _sigint_guard = crate::subprocess_ask::SigintForwarder::install(pid);
470
471 // Merge stderr into stdout via a dedicated drain thread (LD12).
472 // We read stdout + stderr in separate threads and merge into the tee.
473 // This avoids two-pipe deadlock (sigma-review PR #299 finding).
474 let stdout_pipe = child.stdout.take().expect("stdout piped");
475 let stderr_pipe = child.stderr.take().expect("stderr piped");
476
477 // The tee file handle is shared between the stdout drain (main thread)
478 // and the stderr drain (side thread). We use a Mutex.
479 let tee = std::sync::Arc::new(std::sync::Mutex::new(tee_fh));
480 let tee_stderr = tee.clone();
481
482 // Side thread: drain stderr and tee it. Non-fatal on write errors, but
483 // cv-a602835d: a tee-write failure here used to be dropped entirely,
484 // unlike the stdout drain which warns once per distinct error via
485 // `tee_warned`. Mirror that warn-once behavior so a degraded stderr tee
486 // is observable instead of silent. A read error ends the drain.
487 let stderr_handle = std::thread::spawn(move || {
488 let mut stderr_tee_warned: std::collections::HashSet<String> =
489 std::collections::HashSet::new();
490 for line in BufReader::new(stderr_pipe).lines() {
491 match line {
492 Ok(l) => {
493 let raw = format!("{}\n", l);
494 if let Ok(mut guard) = tee_stderr.lock() {
495 if let Err(e) = guard.write_all(raw.as_bytes()) {
496 let key = e.to_string();
497 if stderr_tee_warned.insert(key) {
498 eprintln!("codex provider: stderr tee write failed: {}", e);
499 }
500 } else {
501 // flush best-effort
502 let _ = guard.flush();
503 }
504 }
505 }
506 Err(_) => break,
507 }
508 }
509 });
510
511 // Watchdog: if timeout fires, SIGTERM the pgrp; escalate to SIGKILL after
512 // 2s. Cancelable so a happy-path completion (we `cancel()` before reaping)
513 // skips the kill cascade. The full implementation (incl. the
514 // `Some(Duration::ZERO)` == disabled Python parity and the recv_timeout
515 // cancellation that fixed sigma-review HIGH x2) lives in
516 // `subprocess_ask::AskWatchdog`.
517 let mut watchdog = crate::subprocess_ask::AskWatchdog::spawn(pid, timeout);
518
519 // Main thread: drain stdout, parse JSONL, tee every line.
520 let mut session_id: Option<String> = None;
521 let mut last_msg = String::new();
522 let mut last_error_msg = String::new();
523 let mut types_seen: Vec<String> = Vec::new();
524 let mut tee_warned: std::collections::HashSet<String> = std::collections::HashSet::new();
525 // cv-54a67325: distinguish a clean EOF (the `lines()` iterator ends with
526 // `None`) from a genuine mid-stream read error (`Some(Err(_))`: EPIPE/EIO,
527 // or an invalid-UTF-8 line on the stderr-merged stdout). The bare
528 // `Err(_) => break` here previously swallowed the latter and returned the
529 // partial reply as a "successful" `Ok`, hiding the truncation.
530 let mut stream_read_error: Option<String> = None;
531 let mut broke_on_complete = false;
532
533 let stdout_reader = BufReader::new(stdout_pipe);
534 for raw_line in stdout_reader.lines() {
535 let raw = match raw_line {
536 Ok(l) => l,
537 Err(e) => {
538 // Surface the read error (observability) and stop draining.
539 // The post-loop guard turns a truncation-without-completion
540 // into a loud failure instead of a silent partial success.
541 eprintln!("codex provider: stdout stream read error: {}", e);
542 stream_read_error = Some(e.to_string());
543 break;
544 }
545 };
546 let tee_line = format!("{}\n", raw);
547 // Tee every line (LD8); failure is non-fatal per Python parity.
548 if let Ok(mut guard) = tee.lock() {
549 if let Err(e) = guard.write_all(tee_line.as_bytes()) {
550 let key = e.to_string();
551 if !tee_warned.contains(&key) {
552 tee_warned.insert(key.clone());
553 eprintln!("codex provider: tee write failed: {}", e);
554 }
555 } else {
556 let _ = guard.flush();
557 }
558 }
559
560 // Parse control-flow events.
561 match parse_jsonl_line(&raw) {
562 Some(JsonlEvent::ThreadStarted { thread_id }) => {
563 // Record the event type for forensics regardless of whether
564 // the id is usable (mirrors Python's `types_seen.add(ev_type)`
565 // which runs before the thread_id check).
566 types_seen.push(EV_SESSION.to_string());
567 // cv-dcd823ce (CRITICAL): an EMPTY thread_id (`""`) passes the
568 // `session_id.is_none()` guard as `Some("")`, then sails through
569 // the `expect_session && session_id.is_none()` check below and
570 // gets written to the registry as `codex_session_id: ""`. Every
571 // subsequent `resume` then fails opaquely with "no
572 // codex_session_id; cannot follow up". Treat an empty id as "no
573 // session captured" so the create path fails closed with
574 // NoSessionId (exit 11) instead. (Mirrored in codex.py.)
575 if session_id.is_none() && !thread_id.is_empty() {
576 session_id = Some(thread_id);
577 }
578 }
579 Some(JsonlEvent::AgentMessage { text }) => {
580 types_seen.push(EV_ITEM.to_string());
581 last_msg = text;
582 }
583 Some(JsonlEvent::SoftError { message }) => {
584 types_seen.push(EV_ITEM.to_string());
585 last_error_msg = message;
586 }
587 Some(JsonlEvent::TurnCompleted) => {
588 types_seen.push(EV_COMPLETE.to_string());
589 broke_on_complete = true;
590 break;
591 }
592 Some(JsonlEvent::Other { type_name }) => {
593 if let Some(t) = type_name {
594 if !types_seen.contains(&t) {
595 types_seen.push(t);
596 }
597 }
598 }
599 None => {} // non-JSON banner line; skip
600 }
601 }
602
603 // Cancel watchdog (drop its sender so the kill cascade is skipped). Must
604 // happen BEFORE wait_with_grace so a slow reap doesn't run out the
605 // watchdog's recv_timeout window. The watchdog joins below.
606 watchdog.cancel();
607
608 // Reap the child with grace: wait up to 5s, then SIGTERM, then SIGKILL.
609 let (exit_code, sigkill_escalated) =
610 crate::subprocess_ask::wait_with_grace(pid, &mut child, 5.0);
611
612 // Now that the child is reaped and the watchdog has been signaled to
613 // cancel, join it so any forensic state inside the thread (timed_out
614 // store) is committed before we read it below.
615 watchdog.join();
616
617 // Close stderr drain thread; surface a panic (a bug, distinct from the
618 // expected write-error case the thread already warns about) instead of
619 // swallowing it silently.
620 if stderr_handle.join().is_err() {
621 eprintln!("codex provider: stderr drain thread panicked");
622 }
623
624 let duration_ms = started.elapsed().as_millis() as u64;
625 let was_timed_out = watchdog.timed_out();
626
627 // Operator Ctrl-C (ab-e7fdbcb6 / cv-cfdb7a56): the SIGINT-forwarding
628 // handler (installed via `_sigint_guard`) already relayed the signal to
629 // the codex process group and set this flag. Fail with `Interrupted` (exit
630 // 130) BEFORE the timeout / no-session / exit-code checks so a Ctrl-C'd
631 // run is reported as an interrupt, not misclassified as a timeout or a
632 // missing session id. Mirrors codex.py re-raising KeyboardInterrupt.
633 if crate::subprocess_ask::ask_interrupted() {
634 return Err(CodexAskError::Interrupted);
635 }
636
637 // Check timeout first (Python parity: raise CodexTimeoutError).
638 if was_timed_out {
639 return Err(CodexAskError::Timeout {
640 timeout_sec: timeout.map(|d| d.as_secs_f64()).unwrap_or(0.0),
641 });
642 }
643
644 // Check for missing session_id on create path.
645 if expect_session && session_id.is_none() {
646 // LD14: surface observed types for forensics.
647 types_seen.sort();
648 types_seen.dedup();
649 return Err(CodexAskError::NoSessionId { types_seen });
650 }
651
652 // SIGKILL escalation is always a failure (silent-failure-hunter row 4).
653 if sigkill_escalated {
654 return Err(CodexAskError::SigkillEscalated {
655 partial_exit_code: exit_code,
656 });
657 }
658
659 // cv-54a67325: the stdout drain hit a genuine read error and the stream
660 // did NOT end on a `turn.completed` event. The reply (if any) is partial
661 // and unreliable, so surface it as a hard failure rather than returning a
662 // silently-truncated `Ok`. A read error AFTER `turn.completed` is benign
663 // (we already broke out with the full reply), hence the `!broke_on_complete`
664 // guard. Clean EOF without completion is left to the existing exit-code
665 // path below to preserve Python's lenient "return what we captured"
666 // behavior for non-error stream ends.
667 if let Some(err) = stream_read_error {
668 if !broke_on_complete {
669 return Err(CodexAskError::Invocation {
670 exit_code,
671 message: format!(
672 "stream read error before turn.completed: {} (see output.jsonl)",
673 err
674 ),
675 });
676 }
677 }
678
679 // Non-zero exit with no captured reply is a hard failure.
680 if exit_code != 0 && last_msg.is_empty() {
681 return Err(CodexAskError::Invocation {
682 exit_code,
683 message: format!("see output.jsonl for details"),
684 });
685 }
686
687 // silent-failure-hunter row 5: promote soft-error text when no agent_message.
688 let effective_last_msg = if !last_msg.is_empty() {
689 last_msg
690 } else {
691 last_error_msg
692 };
693
694 Ok(CodexResult {
695 exit_code,
696 session_id,
697 last_msg: effective_last_msg,
698 duration_ms,
699 })
700}
701
702// `kill_pgrp` and `wait_with_grace` now live in `subprocess_ask` (shared with
703// gemini). See `crate::subprocess_ask::{kill_pgrp, wait_with_grace}`.
704
705// ===========================================================================
706// Public create / resume entry points
707// ===========================================================================
708
709/// Spawn `codex exec --json -C <cwd> ...` and parse the JSONL stream.
710/// `agent_self` is the name of this agent (injected into spawn env for nested
711/// `fno agents ask` attribution).
712pub fn codex_create(
713 cwd: &Path,
714 prompt: &str,
715 from_name: &str,
716 yolo: bool,
717 output_path: &Path,
718 timeout: Option<Duration>,
719 agent_self: Option<&str>,
720 model: Option<&str>,
721 reasoning_effort: Option<&str>,
722 add_dir: Option<&str>,
723) -> Result<CodexResult, CodexAskError> {
724 let effective_prompt = normalize_codex_command(prompt);
725 let full_prompt = inject_from_name(&effective_prompt, from_name);
726 // ab-994222ee: the create/exec path is the autonomous headless lane. codex
727 // exec is treated as possibly-blocking, so default to no-prompt
728 // (--dangerously-bypass-approvals-and-sandbox); config.agents.codex.headless_yolo=false opts back in.
729 let eff = crate::agents_config::effective_yolo(
730 yolo,
731 crate::agents_config::headless_yolo_enabled("codex", cwd),
732 );
733 let argv = build_argv_create(cwd, &full_prompt, eff, model, reasoning_effort, add_dir);
734 run_codex(&argv, output_path, timeout, true, None, agent_self)
735}
736
737/// Spawn `codex exec resume <session_id> --json ...` from `cwd`.
738/// Resume does NOT accept `--cd`; cwd is pinned via `Command::current_dir`.
739pub fn codex_resume(
740 session_id: &str,
741 cwd: &Path,
742 prompt: &str,
743 from_name: &str,
744 yolo: bool,
745 output_path: &Path,
746 timeout: Option<Duration>,
747) -> Result<CodexResult, CodexAskError> {
748 let effective_prompt = normalize_codex_command(prompt);
749 let full_prompt = inject_from_name(&effective_prompt, from_name);
750 // ab-994222ee: a resumed autonomous worker is the same headless risk class.
751 let eff = crate::agents_config::effective_yolo(
752 yolo,
753 crate::agents_config::headless_yolo_enabled("codex", cwd),
754 );
755 let argv = build_argv_resume(session_id, &full_prompt, eff);
756 run_codex(&argv, output_path, timeout, false, Some(cwd), None)
757}
758
759// ===========================================================================
760// Dispatch orchestrator (Wave B2)
761// ===========================================================================
762
763/// Derive the stable log path for a codex agent (mirrors Python `_codex_output_path`).
764fn derive_log_path(home: &AgentsHome, name: &str) -> PathBuf {
765 home.root()
766 .join("agents")
767 .join("logs")
768 .join(format!("{}.jsonl", name))
769}
770
771/// UTC second-precision timestamp (mirrors `claude_ask::now_iso`).
772fn now_iso() -> String {
773 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
774}
775
776/// RAII per-agent flock (same primitive as `claude_ask::AgentLock`).
777struct AgentLock {
778 _file: std::fs::File,
779}
780
781impl AgentLock {
782 fn acquire(home: &AgentsHome, name: &str, timeout: Duration) -> Result<Self, ()> {
783 let locks_dir = home.root().join("locks");
784 let _ = std::fs::create_dir_all(&locks_dir);
785 let path = locks_dir.join(format!("{}.lock", name));
786 let file = std::fs::OpenOptions::new()
787 .create(true)
788 .truncate(false)
789 .write(true)
790 .open(&path)
791 .map_err(|_| ())?;
792 let deadline = Instant::now() + timeout;
793 loop {
794 match file.try_lock() {
795 Ok(()) => return Ok(Self { _file: file }),
796 Err(_) => {
797 if Instant::now() >= deadline {
798 return Err(());
799 }
800 std::thread::sleep(Duration::from_millis(25));
801 }
802 }
803 }
804 }
805}
806
807impl Drop for AgentLock {
808 fn drop(&mut self) {
809 // std's inherent File::unlock (stable since Rust 1.89; the crate now
810 // pins rust-version = 1.89). Mirrors acquire()'s std locking.
811 let _ = self._file.unlock();
812 }
813}
814
815/// Outcome of `dispatch_codex_ask`.
816#[derive(Debug, Clone, PartialEq, Eq)]
817pub struct AskOutcome {
818 pub stdout: String,
819 pub stderr: String,
820 pub exit_code: i32,
821}
822
823impl AskOutcome {
824 fn ok_reply(reply: String) -> Self {
825 Self {
826 stdout: reply,
827 stderr: String::new(),
828 exit_code: 0,
829 }
830 }
831 fn err(msg: impl Into<String>, code: i32) -> Self {
832 Self {
833 stdout: String::new(),
834 stderr: format!("{}\n", msg.into()),
835 exit_code: code,
836 }
837 }
838}
839
840// Validation reuses `claude_ask::validate_inputs` directly — it is the
841// canonical pre-flight gate for `ask` across all providers, mirroring
842// Python's `dispatch.py::_validate_inputs` + `_validate_from_name`. The
843// previous local copy here was missing three hardening checks the claude
844// version enforces: short-id collision rejection (^[0-9a-f]{8}$), forbidden
845// env chars in name (\0 \n \r =), and from_name length + XML-safety
846// (sigma-review code-reviewer I1).
847
848/// Orchestrate one codex `ask`: validate, lock, decide create-vs-resume,
849/// stamp the registry, emit events, and return stdout/stderr/exit_code.
850///
851/// `extra_env` is unused for codex (codex inherits the process env directly);
852/// the parameter exists for API symmetry with `dispatch_claude_ask`.
853#[allow(clippy::too_many_arguments)]
854pub fn dispatch_codex_ask(
855 home: &AgentsHome,
856 name: &str,
857 message: &str,
858 from_name: &str,
859 // Create-only input, retained for API stability after Task 1.3a removed
860 // the create branch from `ask` (`spawn --once` owns creation now).
861 _cwd: &Path,
862 yolo: bool,
863 timeout: Option<Duration>,
864) -> AskOutcome {
865 if let Err(msg) = crate::claude_ask::validate_inputs(name, message, from_name) {
866 return AskOutcome::err(msg, 2);
867 }
868
869 let events = home.events_jsonl();
870 let registry_path = home.registry_json();
871
872 let _lock = match AgentLock::acquire(home, name, LOCK_ACQUIRE_TIMEOUT) {
873 Ok(l) => l,
874 Err(()) => {
875 emit_event(
876 &events,
877 "agent_ask_failed",
878 &[
879 ("stage", "lock-timeout".into()),
880 ("name", name.into()),
881 ("provider", "codex".into()),
882 ],
883 );
884 return AskOutcome::err(
885 format!(
886 "lock timeout for agent {:?} after {:.1}s",
887 name,
888 LOCK_ACQUIRE_TIMEOUT.as_secs_f64()
889 ),
890 11,
891 );
892 }
893 };
894
895 let registry = match load_registry(®istry_path) {
896 Ok(r) => r,
897 Err(e) => {
898 emit_event(
899 &events,
900 "agent_ask_failed",
901 &[
902 ("stage", "registry-read".into()),
903 ("name", name.into()),
904 ("provider", "codex".into()),
905 ("error", e.to_string().into()),
906 ],
907 );
908 return AskOutcome::err(format!("registry read failed: {}", e), 12);
909 }
910 };
911
912 let existing = registry.find(name).cloned();
913
914 match existing {
915 None => {
916 // ask never creates (Task 1.3a): unknown-name -> exit 16, byte-parity
917 // with Python's dispatch_ask after Task 1.1.
918 emit_event(
919 &events,
920 "agent_ask_failed",
921 &[
922 ("stage", "unknown-name".into()),
923 ("name", name.into()),
924 ("provider", "codex".into()),
925 ],
926 );
927 AskOutcome::err(
928 format!(
929 "unknown agent {}; spawn it first: fno agents spawn {} --harness <harness>",
930 crate::claude_ask::py_repr(name),
931 name
932 ),
933 16,
934 )
935 }
936 Some(entry) => dispatch_resume(
937 home,
938 &events,
939 ®istry_path,
940 name,
941 &entry,
942 message,
943 from_name,
944 yolo,
945 timeout,
946 ),
947 }
948}
949
950/// Orchestrate one codex `spawn --once`: validate, lock, collision-check,
951/// create + exchange, teardown registry row, return reply on stdout and
952/// teardown receipt on stderr. Reuses `dispatch_create` machinery.
953///
954/// Tests inject PATH via `std::env::set_var` before calling (the same mutex
955/// pattern as `dispatch_codex_ask` tests), since `run_codex` inherits the
956/// process environment directly.
957#[allow(clippy::too_many_arguments)]
958pub fn dispatch_codex_once(
959 home: &AgentsHome,
960 name: &str,
961 message: &str,
962 from_name: &str,
963 cwd: &Path,
964 yolo: bool,
965 timeout: Option<Duration>,
966 model: Option<&str>,
967 reasoning_effort: Option<&str>,
968 add_dir: Option<&str>,
969) -> AskOutcome {
970 use crate::claude_ask::py_repr;
971
972 // spawn allows an empty initial message (Python dispatch_spawn parity);
973 // the once path defaults it to "hello" below.
974 if let Err(msg) = crate::claude_ask::validate_spawn_inputs(name, from_name) {
975 return AskOutcome::err(msg, 2);
976 }
977
978 let events = home.events_jsonl();
979 let registry_path = home.registry_json();
980
981 let _lock = match AgentLock::acquire(home, name, LOCK_ACQUIRE_TIMEOUT) {
982 Ok(l) => l,
983 Err(()) => {
984 emit_event(
985 &events,
986 "agent_ask_failed",
987 &[
988 ("stage", "lock-timeout".into()),
989 ("name", name.into()),
990 ("provider", "codex".into()),
991 ],
992 );
993 return AskOutcome::err(
994 format!(
995 "lock timeout for agent {} after {:.1}s",
996 py_repr(name),
997 LOCK_ACQUIRE_TIMEOUT.as_secs_f64()
998 ),
999 11,
1000 );
1001 }
1002 };
1003
1004 // Collision check INSIDE the lock (mirrors Python dispatch_spawn 4a).
1005 let registry = match load_registry(®istry_path) {
1006 Ok(r) => r,
1007 Err(e) => {
1008 return AskOutcome::err(format!("registry read failed: {}", e), 12);
1009 }
1010 };
1011 if registry.find(name).is_some() {
1012 // Python: f"agent {name!r} already exists; ..." -> py_repr, not {:?}.
1013 return AskOutcome::err(
1014 format!(
1015 "agent {} already exists; use 'fno agents rm {}' first or pick another name",
1016 py_repr(name),
1017 name
1018 ),
1019 2,
1020 );
1021 }
1022
1023 // Create + exchange using the retained dispatch_create machinery.
1024 // Python parity: dispatch_spawn passes `message or "hello"` on the once
1025 // paths - Python truthiness, so ONLY the empty string becomes "hello";
1026 // a whitespace-only message is truthy and passes through unchanged
1027 // (sigma-review parity finding: trim() here would diverge).
1028 let effective_message = if message.is_empty() { "hello" } else { message };
1029 let inner = dispatch_create(
1030 home,
1031 &events,
1032 ®istry_path,
1033 name,
1034 effective_message,
1035 from_name,
1036 cwd,
1037 yolo,
1038 timeout,
1039 model,
1040 reasoning_effort,
1041 add_dir,
1042 );
1043 if inner.exit_code != 0 {
1044 // create failed; dispatch_create only writes the registry post-success,
1045 // so no row was left behind (invariant pinned by test).
1046 return inner;
1047 }
1048
1049 // Capture session id from the registry row that dispatch_create wrote.
1050 let session_or_short_id = load_registry(®istry_path)
1051 .ok()
1052 .and_then(|r| r.find(name).and_then(|e| e.codex_session_id.clone()))
1053 .unwrap_or_default();
1054
1055 // Teardown: remove the registry row the create helper wrote.
1056 let teardown_err = update_registry(®istry_path, |reg| {
1057 reg.entries.retain(|e| e.name != name);
1058 true
1059 })
1060 .err();
1061
1062 let teardown_receipt = if let Some(e) = teardown_err {
1063 // AC2-FR: loud warning, row stays visible, exit 0 still.
1064 // Python: f"... teardown failed for {name!r} ..." -> py_repr.
1065 format!(
1066 "fno agents spawn: warning: teardown failed for {} (codex/{}): {}. Peer leaked -- clean up via 'fno agents rm {}'\n",
1067 py_repr(name),
1068 session_or_short_id,
1069 e,
1070 name
1071 )
1072 } else {
1073 // Teardown receipt on stderr (AC2-UI), byte-parity with Python:
1074 // f"once: {name} ({provider}/{session_or_short_id}) torn down"
1075 format!("once: {} (codex/{}) torn down\n", name, session_or_short_id)
1076 };
1077
1078 AskOutcome {
1079 stdout: inner.stdout,
1080 stderr: teardown_receipt,
1081 exit_code: 0,
1082 }
1083}
1084
1085fn dispatch_create(
1086 home: &AgentsHome,
1087 events: &Path,
1088 registry_path: &Path,
1089 name: &str,
1090 message: &str,
1091 from_name: &str,
1092 cwd: &Path,
1093 yolo: bool,
1094 timeout: Option<Duration>,
1095 model: Option<&str>,
1096 reasoning_effort: Option<&str>,
1097 add_dir: Option<&str>,
1098) -> AskOutcome {
1099 let output_path = derive_log_path(home, name);
1100 let timeout_sec = timeout.unwrap_or(DEFAULT_FOLLOWUP_TIMEOUT);
1101
1102 let result = match codex_create(
1103 cwd,
1104 message,
1105 from_name,
1106 yolo,
1107 &output_path,
1108 Some(timeout_sec),
1109 Some(name),
1110 model,
1111 reasoning_effort,
1112 add_dir,
1113 ) {
1114 Ok(r) => r,
1115 Err(e) => {
1116 let stage = match &e {
1117 CodexAskError::NoSessionId { .. } => "codex-no-session",
1118 CodexAskError::Timeout { .. } => "codex-timeout",
1119 CodexAskError::Interrupted => "codex-interrupted",
1120 _ => "codex-subprocess",
1121 };
1122 let exit_code = e.exit_code();
1123 // cv-9bc2abe7: append the output.jsonl path so the operator knows
1124 // where to find the partial reply / stderr a timeout-killed (or
1125 // otherwise failed) codex captured before dying. The resume path
1126 // already does this; the create path previously surfaced the bare
1127 // error with no log-file breadcrumb.
1128 let msg = format!("{} (see {} for details)", e, output_path.display());
1129 emit_event(
1130 events,
1131 "agent_ask_failed",
1132 &[
1133 ("stage", stage.into()),
1134 ("name", name.into()),
1135 ("provider", "codex".into()),
1136 ("returncode", exit_code.into()),
1137 ],
1138 );
1139 // exit_code is already mapped to the user-visible code by
1140 // CodexAskError::exit_code() (NotFound -> 14, SigkillEscalated ->
1141 // partial code, etc.). No further remap needed here.
1142 return AskOutcome::err(msg, exit_code);
1143 }
1144 };
1145
1146 // `run_codex` with `expect_session=true` (the create path) guarantees
1147 // session_id is Some on Ok via the NoSessionId guard above. `expect`
1148 // converts a silent stamping of an empty `codex_session_id` (which
1149 // would then fail every subsequent resume with an opaque "cannot
1150 // follow up") into a loud panic with context (sigma-review type-design
1151 // HIGH).
1152 let session_id = result
1153 .session_id
1154 .expect("codex_create guarantees session_id on success (expect_session=true)");
1155
1156 // Build the registry entry.
1157 use crate::state::RegistryEntry;
1158 let new_entry = RegistryEntry {
1159 name: name.to_string(),
1160 short_id: String::new(),
1161 legacy_provider: String::new(),
1162 harness: Some("codex".to_string()),
1163 harness_session_id: Some(session_id.clone()),
1164 cwd: cwd.to_string_lossy().to_string(),
1165 project_root: String::new(),
1166 session_id: None,
1167 legacy_claude_short_id: None,
1168 claude_session_uuid: None,
1169 messaging_socket_path: None,
1170 codex_session_id: Some(session_id.clone()),
1171 gemini_session_id: None,
1172 mcp_channel_id: None,
1173 host_mode: None, // codex ask = exec one-shot (not an interactive host)
1174 cc_session_id: None,
1175 // Stamped Live at creation: the just-finished one-shot is momentarily
1176 // live and immediately promotable/visible in `grid --all`, and the row
1177 // records a resumable session (codex resume <uuid>). It is NOT a
1178 // permanent Live: `reconcile` settles a finished ask to `Exited` by
1179 // process-liveness alone (plan ab-70faa65b, Locked Decision #1 -- a
1180 // surviving session file is "resumable", not "running", so it must not
1181 // keep the row `live`). promote is unaffected because admit_promote
1182 // admits a settled `Exited` exec source (see admit_promote_exited_source_
1183 // is_promotable); the only post-reconcile change is that a settled ask
1184 // drops out of `grid --all`'s alive-ish tiling (carveout cv-ba2b2048).
1185 // Supersedes the earlier fu-663c8b "intentionally permanent-Live"
1186 // rationale.
1187 status: AgentStatus::Live,
1188 last_message_at: None,
1189 created_at: now_iso(),
1190 pid: None,
1191 pid_start_time: None,
1192 log_path: Some(output_path.to_string_lossy().to_string()),
1193 last_reconciled_at: None,
1194 inside_leg: None,
1195 exited_at: None,
1196 mux: None,
1197 screen_state: None,
1198 crown_level: None,
1199 crown_scope: None,
1200 crown_grantor: None,
1201 };
1202
1203 match update_registry(registry_path, |reg| {
1204 if reg.find(name).is_some() {
1205 false
1206 } else {
1207 reg.entries.push(new_entry.clone());
1208 true
1209 }
1210 }) {
1211 Ok(true) => {}
1212 Ok(false) => {
1213 emit_event(
1214 events,
1215 "agent_ask_failed",
1216 &[
1217 ("stage", "name-collision".into()),
1218 ("name", name.into()),
1219 ("provider", "codex".into()),
1220 ("codex_session_id", session_id.clone().into()),
1221 ],
1222 );
1223 return AskOutcome::err(
1224 format!(
1225 "agent {:?} already exists (registered concurrently); orphaned codex session: {:?}",
1226 name, session_id
1227 ),
1228 12,
1229 );
1230 }
1231 Err(e) => {
1232 emit_event(
1233 events,
1234 "agent_ask_failed",
1235 &[
1236 ("stage", "registry-write".into()),
1237 ("name", name.into()),
1238 ("provider", "codex".into()),
1239 ("codex_session_id", session_id.clone().into()),
1240 ],
1241 );
1242 return AskOutcome::err(
1243 format!(
1244 "registry write failed: {}. orphaned codex session: {:?} (see output.jsonl)",
1245 e, session_id
1246 ),
1247 12,
1248 );
1249 }
1250 }
1251
1252 emit_event(
1253 events,
1254 "agent_ask_done",
1255 &[
1256 ("stage", "dispatch".into()),
1257 ("name", name.into()),
1258 ("provider", "codex".into()),
1259 ("codex_session_id", session_id.clone().into()),
1260 ("duration_ms", (result.duration_ms as u64).into()),
1261 ("yolo", yolo.into()),
1262 ],
1263 );
1264
1265 // Codex create returns the reply verbatim (no short_id banner).
1266 // `kind="followup"` semantics: stdout = reply text, no trailing newline.
1267 AskOutcome::ok_reply(result.last_msg)
1268}
1269
1270fn dispatch_resume(
1271 _home: &AgentsHome,
1272 events: &Path,
1273 registry_path: &Path,
1274 name: &str,
1275 entry: &crate::state::RegistryEntry,
1276 message: &str,
1277 from_name: &str,
1278 yolo: bool,
1279 timeout: Option<Duration>,
1280) -> AskOutcome {
1281 let session_id = match entry.codex_session_id.as_deref() {
1282 Some(s) if !s.is_empty() => s.to_string(),
1283 _ => {
1284 return AskOutcome::err(
1285 format!(
1286 "registry entry {:?} has no codex_session_id; cannot follow up. \
1287 Remove with 'fno agents rm {}' and recreate.",
1288 name, name
1289 ),
1290 11,
1291 );
1292 }
1293 };
1294
1295 let log_path = match entry.log_path.as_deref() {
1296 Some(p) if !p.is_empty() => PathBuf::from(p),
1297 _ => {
1298 return AskOutcome::err(
1299 format!(
1300 "registry entry {:?} has empty log_path; run 'fno agents rm {}' and recreate.",
1301 name, name
1302 ),
1303 11,
1304 );
1305 }
1306 };
1307
1308 let registered_cwd = match entry.cwd.as_str() {
1309 "" => {
1310 return AskOutcome::err(
1311 format!(
1312 "registry entry {:?} has empty cwd; codex sessions are cwd-pinned. \
1313 Run 'fno agents rm {}' and recreate.",
1314 name, name
1315 ),
1316 11,
1317 );
1318 }
1319 c => PathBuf::from(c),
1320 };
1321
1322 emit_event(
1323 events,
1324 "agent_followup_started",
1325 &[
1326 ("name", name.into()),
1327 ("provider", "codex".into()),
1328 ("codex_session_id", session_id.clone().into()),
1329 ("yolo", yolo.into()),
1330 ],
1331 );
1332
1333 let timeout_sec = timeout.unwrap_or(DEFAULT_FOLLOWUP_TIMEOUT);
1334 let result = match codex_resume(
1335 &session_id,
1336 ®istered_cwd,
1337 message,
1338 from_name,
1339 yolo,
1340 &log_path,
1341 Some(timeout_sec),
1342 ) {
1343 Ok(r) => r,
1344 Err(e) => {
1345 let stage = match &e {
1346 CodexAskError::Timeout { .. } => "codex-timeout",
1347 CodexAskError::Interrupted => "codex-interrupted",
1348 _ => "codex-subprocess",
1349 };
1350 let exit_code = e.exit_code();
1351 emit_event(
1352 events,
1353 "agent_followup_failed",
1354 &[
1355 ("stage", stage.into()),
1356 ("name", name.into()),
1357 ("provider", "codex".into()),
1358 ("codex_session_id", session_id.clone().into()),
1359 ("returncode", exit_code.into()),
1360 ],
1361 );
1362 let msg = format!(
1363 "{} (see {} for details). If the session was lost, run 'fno agents rm {}' then re-ask.",
1364 e, log_path.display(), name
1365 );
1366 return AskOutcome::err(msg, exit_code);
1367 }
1368 };
1369
1370 // Stamp status=live + last_message_at on success.
1371 if let Err(e) = update_registry(registry_path, |reg| {
1372 if let Some(en) = reg.find_mut(name) {
1373 en.status = AgentStatus::Live;
1374 en.last_message_at = Some(now_iso());
1375 }
1376 }) {
1377 emit_event(
1378 events,
1379 "agent_followup_failed",
1380 &[
1381 ("stage", "registry-write".into()),
1382 ("name", name.into()),
1383 ("provider", "codex".into()),
1384 ("codex_session_id", session_id.clone().into()),
1385 ("error", e.to_string().into()),
1386 ],
1387 );
1388 return AskOutcome::err(
1389 format!(
1390 "registry write failed: {}. NOTE: message was already delivered; do not retry. \
1391 (agent={:?} session={:?})",
1392 e, name, session_id
1393 ),
1394 12,
1395 );
1396 }
1397
1398 emit_event(
1399 events,
1400 "agent_followup_done",
1401 &[
1402 ("stage", "followup".into()),
1403 ("name", name.into()),
1404 ("provider", "codex".into()),
1405 ("codex_session_id", session_id.clone().into()),
1406 (
1407 "reply_chars",
1408 (result.last_msg.chars().count() as u64).into(),
1409 ),
1410 ("yolo", yolo.into()),
1411 ],
1412 );
1413
1414 AskOutcome::ok_reply(result.last_msg)
1415}
1416
1417// ===========================================================================
1418// Client entry point (called from bin/client.rs)
1419// ===========================================================================
1420
1421/// Route a codex `ask` to the client-side `codex exec` path, bypassing the
1422/// daemon (mirrors `maybe_run_claude_ask` in client.rs).
1423///
1424/// Returns `Some(exit_code)` when the target is codex, or `None` to fall
1425/// through to the daemon RPC for gemini.
1426pub fn maybe_run_codex_ask(
1427 home: &AgentsHome,
1428 params: &serde_json::Value,
1429 name: &str,
1430) -> Option<i32> {
1431 let provider_param = params.get("provider").and_then(|v| v.as_str());
1432 // A corrupt registry must NOT silently degrade to "empty registry" for
1433 // the routing decision: that path could mis-route a codex agent
1434 // already registered with provider=codex to the Python dispatch (or
1435 // worse, route a registered claude agent to the codex branch if
1436 // --provider codex is supplied). Surface the failure via stderr WARN
1437 // and fall through to None (let Python handle it -- the same dispatch
1438 // there will surface a structured error). Sigma-review silent-failure
1439 // HIGH.
1440 //
1441 // Codex PR #371 follow-up: returning None here does NOT actually fall
1442 // through to Python -- once `fno` has exec'd the Rust client we're
1443 // already in-process, so None falls through to the daemon RPC path
1444 // (whose own registry read defaults to an empty registry, then spawns
1445 // a fresh PTY worker). That diverges from Python's contract, which
1446 // surfaces "registry read failed" as exit 12 before any side effect.
1447 // Surface the failure as exit 12 + stderr error, matching Python.
1448 let registry = match load_registry(&home.registry_json()) {
1449 Ok(r) => r,
1450 Err(e) => {
1451 eprintln!(
1452 "fno-agents: cannot read agents registry at {:?}: {}",
1453 home.registry_json(),
1454 e
1455 );
1456 return Some(12);
1457 }
1458 };
1459 let existing_provider = registry.find(name).map(|e| e.harness_name().to_string());
1460
1461 // Provider mismatch guard (mirrors claude path).
1462 if let (Some(ep), Some(pp)) = (existing_provider.as_deref(), provider_param) {
1463 if ep == "codex" && pp != "codex" {
1464 eprintln!(
1465 "fno-agents: agent {:?} already exists with provider 'codex'; \
1466 refusing to override with --provider {}",
1467 name, pp
1468 );
1469 return Some(2);
1470 }
1471 }
1472
1473 let resolved = existing_provider.as_deref().or(provider_param);
1474 if resolved != Some("codex") {
1475 return None; // not a codex target; fall through
1476 }
1477
1478 let message = params.get("message").and_then(|v| v.as_str()).unwrap_or("");
1479 let from_name = params
1480 .get("from_name")
1481 .and_then(|v| v.as_str())
1482 .unwrap_or("fno");
1483 // cv-16eb2200: resolve_ask_cwd warns at the canonicalize-fallback point.
1484 let cwd = crate::subprocess_ask::resolve_ask_cwd(params.get("cwd").and_then(|v| v.as_str()));
1485 let timeout = params
1486 .get("timeout")
1487 .and_then(|v| v.as_u64())
1488 .map(std::time::Duration::from_secs);
1489 let yolo = params
1490 .get("yolo")
1491 .and_then(|v| v.as_bool())
1492 .unwrap_or(false);
1493
1494 let outcome = dispatch_codex_ask(home, name, message, from_name, &cwd, yolo, timeout);
1495 if !outcome.stderr.is_empty() {
1496 eprint!("{}", outcome.stderr);
1497 }
1498 if !outcome.stdout.is_empty() {
1499 print!("{}", outcome.stdout);
1500 }
1501 Some(outcome.exit_code)
1502}