Skip to main content

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(&registry_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            &registry_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(&registry_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        &registry_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(&registry_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(&registry_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        &registered_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}