Skip to main content

omni_dev/sessions/
codex_watcher.rs

1//! The Codex rollout watcher, which supplements the Codex hook feed.
2//!
3//! An engine-owned background task over
4//! `$CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl` (#1909, ADR-0087), the
5//! Codex twin of the Claude transcript [`watcher`](super::watcher).
6//!
7//! Hooks own a Codex session's *state*; this watcher covers what they miss:
8//!
9//! 1. **Discovery** — a session started before the daemon (or before the hooks
10//!    were installed and trusted) still has a rollout file. Its head-of-file
11//!    `session_meta` line yields the session's id and `cwd`, so the session lands
12//!    on the right worktree row even before a hook fires.
13//! 2. **Ends hooks cannot report.** Archiving an IDE or Desktop chat *moves* its
14//!    rollout into `$CODEX_HOME/archived_sessions/`, which is outside the watched
15//!    tree, so a rollout that vanishes reads as an end. And a Codex process killed
16//!    by SIGHUP (closing its terminal tab), SIGTERM or SIGKILL fires no
17//!    `SessionEnd`: while a thread is loaded, Codex holds an exclusive `flock` on
18//!    `$CODEX_HOME/thread-writer-locks/<session-id>.lock`, and the kernel drops it
19//!    when the process dies, however it dies. A lock this watcher has seen held
20//!    that is later free (or gone) ends the session, instead of it lingering for
21//!    the registry's TTL.
22//! 3. **Idle liveness.** An idle Codex session emits nothing, so it would age out
23//!    on the TTL while its process lives on. A held lock is re-reported as a
24//!    state-preserving heartbeat.
25//!
26//! What it reads, and what it never does:
27//!
28//! - Only the **first line** of a rollout (`session_meta`, bounded to
29//!   [`MAX_HEAD_BYTES`]), and from it only `id`/`session_id`, `cwd` and `source`;
30//!   never a conversation line. Growth is size/mtime only. Nothing is logged.
31//! - **Subagent threads are skipped**: their `source` is an object
32//!   (`{"subagent": …}`), and their hook events already carry the parent's id.
33//! - It never reports a *state*: growth is a heartbeat, not `working`, because a
34//!   rollout keeps being written around the turn's `Stop` and would otherwise
35//!   hold an idle session at `working`. So a session only this watcher sees reads
36//!   `idle`. It can never report an approval either — the rollout records none.
37//! - The lock probe is a non-blocking **shared** `flock` taken and dropped at
38//!   once: on a session's first sight, then once per scan while it is tracked
39//!   and not ended (an ended session is left alone until its rollout grows).
40//!   Against a live holder it fails harmlessly; it can only collide with Codex
41//!   acquiring the lock for that same thread in the same instant.
42//! - On first sight a rollout whose lock is free or gone is recorded but not
43//!   announced — a daemon restart must not list sessions that already ended.
44//! - A rollout that is *not* recent is no evidence the session is gone: a Codex
45//!   left idle at its prompt writes nothing, and one started before hooks were
46//!   installed has no hook to announce it either (#2108). So an unread rollout's
47//!   lock is probed too, by the thread id its file name ends in, on every scan;
48//!   a held lock announces it whatever its mtime. The name is only a prefilter
49//!   (a missing lock file is a cheap `ENOENT`): the head, read once the lock is
50//!   held, stays the authority for the id, `cwd` and the subagent check.
51
52use std::collections::{HashMap, HashSet};
53use std::io::{BufRead, BufReader, Read};
54use std::path::{Path, PathBuf};
55use std::sync::Arc;
56use std::time::{Duration, SystemTime};
57
58use serde::Deserialize;
59use tokio::task::JoinHandle;
60use tokio_util::sync::CancellationToken;
61
62use super::{is_session_uuid, Agent, ObserveRequest, SessionEvent, SessionsRegistry};
63
64/// How often the lock of a rollout that has not been read is probed (#2108). A
65/// Codex home accumulates thousands of old rollouts, so probing each of them on
66/// every [`WATCH_INTERVAL`] would cost thousands of syscalls every few seconds for
67/// the daemon's whole life; a held lock on an old rollout is found within this
68/// instead (or at once, when the rollout is written to).
69const UNREAD_PROBE_INTERVAL: Duration = Duration::from_secs(60);
70
71/// Codex's home directory override, as Codex itself reads it.
72const CODEX_HOME_ENV: &str = "CODEX_HOME";
73
74/// How often the watcher rescans. Matches the Claude transcript watcher.
75const WATCH_INTERVAL: Duration = Duration::from_secs(5);
76
77/// A rollout must have been modified within this window to be announced when
78/// first seen, so a fresh daemon does not flood the registry with every
79/// historical session. Matches the registry's session TTL.
80const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(300);
81
82/// How often a live session (lock held, or rollout growing) is re-reported, to
83/// keep it inside the registry's 5-minute TTL without a report every scan.
84const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60);
85
86/// The most of a rollout's head line the watcher will read. `session_meta`
87/// embeds Codex's base instructions (~20 KiB today); a head without a newline
88/// within this bound is treated as unreadable.
89const MAX_HEAD_BYTES: u64 = 1024 * 1024;
90
91/// Where Codex keeps its state: `$CODEX_HOME`, else `~/.codex`.
92fn codex_home() -> Option<PathBuf> {
93    if let Some(dir) = std::env::var_os(CODEX_HOME_ENV).filter(|d| !d.is_empty()) {
94        return Some(PathBuf::from(dir));
95    }
96    dirs::home_dir().map(|home| home.join(".codex"))
97}
98
99/// The fields of a rollout's head line the watcher reads. Everything else,
100/// including the embedded instructions, is skipped by serde.
101#[derive(Debug, Deserialize)]
102struct HeadLine {
103    #[serde(rename = "type")]
104    kind: String,
105    #[serde(default)]
106    payload: Option<SessionMeta>,
107}
108
109/// The `session_meta` payload fields the watcher reads.
110#[derive(Debug, Deserialize)]
111struct SessionMeta {
112    /// The thread id — the hook `session_id`, the rollout filename's id and the
113    /// lock filename, for a non-subagent thread.
114    #[serde(default)]
115    id: Option<String>,
116    #[serde(default)]
117    session_id: Option<String>,
118    #[serde(default)]
119    cwd: Option<PathBuf>,
120    /// `"cli"`, `"vscode"`, `"exec"`, … for a session; an object
121    /// (`{"subagent": …}`) for a subagent thread.
122    #[serde(default)]
123    source: Option<serde_json::Value>,
124}
125
126/// What a rollout's head says about it.
127#[derive(Debug, Clone, PartialEq, Eq)]
128enum Head {
129    /// A top-level session.
130    Session { id: String, cwd: Option<PathBuf> },
131    /// A subagent thread, or a head the watcher cannot read — never reported.
132    Skip,
133    /// The first line is not complete yet (Codex is still writing it); read
134    /// again once the file grows.
135    Incomplete,
136}
137
138/// Parses a rollout head line. Schema-tolerant: anything but a `session_meta`
139/// line with an id is [`Head::Skip`].
140fn parse_head(line: &str) -> Head {
141    let Ok(head) = serde_json::from_str::<HeadLine>(line) else {
142        return Head::Skip;
143    };
144    let Some(meta) = head.payload.filter(|_| head.kind == "session_meta") else {
145        return Head::Skip;
146    };
147    if meta
148        .source
149        .as_ref()
150        .is_some_and(serde_json::Value::is_object)
151    {
152        return Head::Skip;
153    }
154    match meta
155        .id
156        .or(meta.session_id)
157        .filter(|id| !id.trim().is_empty())
158    {
159        Some(id) => Head::Session { id, cwd: meta.cwd },
160        None => Head::Skip,
161    }
162}
163
164/// Reads and parses the first line of `path`, bounded to [`MAX_HEAD_BYTES`].
165fn read_head(path: &Path) -> Head {
166    let Ok(file) = std::fs::File::open(path) else {
167        return Head::Skip;
168    };
169    let mut line = String::new();
170    let mut reader = BufReader::new(file.take(MAX_HEAD_BYTES));
171    match reader.read_line(&mut line) {
172        Ok(_) if line.ends_with('\n') => parse_head(&line),
173        // Hit the bound without a newline: not a head this watcher will read.
174        Ok(n) if n as u64 >= MAX_HEAD_BYTES => Head::Skip,
175        Ok(_) => Head::Incomplete,
176        Err(_) => Head::Skip,
177    }
178}
179
180/// The state of a thread's writer lock.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub(crate) enum LockState {
183    /// Codex holds it: the thread is loaded in a live process.
184    Held,
185    /// The file exists and nobody holds it: the holder exited or was killed.
186    Free,
187    /// No lock file in an existing lock directory: the thread is not loaded.
188    Absent,
189    /// The probe failed for another reason; treated as no information.
190    Unknown,
191}
192
193/// Probes `<locks>/<id>.lock` with a non-blocking shared `flock`, released at
194/// once. See the module docs for why this cannot disturb a live holder.
195#[cfg(unix)]
196fn probe_lock(locks: &Path, id: &str) -> LockState {
197    use nix::errno::Errno;
198    use nix::fcntl::{Flock, FlockArg};
199
200    let file = match std::fs::File::open(locks.join(format!("{id}.lock"))) {
201        Ok(file) => file,
202        // Without the directory this Codex keeps no locks at all, which says
203        // nothing about the session.
204        Err(e) if e.kind() == std::io::ErrorKind::NotFound && locks.is_dir() => {
205            return LockState::Absent
206        }
207        Err(_) => return LockState::Unknown,
208    };
209    match Flock::lock(file, FlockArg::LockSharedNonblock) {
210        // Dropping the guard releases the shared lock straight away.
211        Ok(_guard) => LockState::Free,
212        Err((_, Errno::EWOULDBLOCK)) => LockState::Held,
213        Err(_) => LockState::Unknown,
214    }
215}
216
217#[cfg(not(unix))]
218fn probe_lock(_locks: &Path, _id: &str) -> LockState {
219    LockState::Unknown
220}
221
222/// The state of thread `id`'s writer lock under the resolved Codex home, for the
223/// journal replay (#2108): a held lock is proof a live process has the thread
224/// loaded, a free or absent one that it is gone. [`LockState::Unknown`] when no
225/// Codex home resolves.
226pub(crate) fn probe_thread_lock(id: &str) -> LockState {
227    probe_thread_lock_in(codex_home().as_deref(), id)
228}
229
230/// [`probe_thread_lock`] under an already-resolved Codex home.
231fn probe_thread_lock_in(home: Option<&Path>, id: &str) -> LockState {
232    match home {
233        Some(home) => probe_lock(&home.join("thread-writer-locks"), id),
234        None => LockState::Unknown,
235    }
236}
237
238/// The watcher's record of one rollout file.
239#[derive(Debug, Clone)]
240enum Tracked {
241    /// Seen but not read: it was not recently active when first seen. Its head
242    /// is read if it later grows, or when its thread lock is found held.
243    /// `probed` is when that lock was last looked at (#2108).
244    Unread { size: u64, probed: SystemTime },
245    /// A subagent or unreadable rollout; never reported.
246    Skipped,
247    /// A session rollout.
248    Session(SessionTrack),
249}
250
251/// Per-session bookkeeping for a [`Tracked::Session`].
252#[derive(Debug, Clone)]
253struct SessionTrack {
254    id: String,
255    cwd: Option<PathBuf>,
256    size: u64,
257    /// Whether this watcher has seen the thread's lock held. Only then does a
258    /// free or missing lock mean the process is gone — a Codex without locks,
259    /// or a thread that was never loaded, must not read as ended.
260    lock_seen: bool,
261    /// Whether this watcher ended the session (it stops reporting it until the
262    /// rollout grows again, e.g. `codex resume`).
263    ended: bool,
264    /// When the session was last reported.
265    last_report: SystemTime,
266}
267
268/// The watcher's state across scans, keyed by rollout path.
269type ScanState = HashMap<PathBuf, Tracked>;
270
271/// One thing a scan asks the registry to do.
272#[derive(Debug, Clone, PartialEq, Eq)]
273enum Action {
274    /// Report the session as present, keeping its current state.
275    Seen {
276        id: String,
277        cwd: Option<PathBuf>,
278        transcript_path: PathBuf,
279    },
280    /// End the session.
281    End { id: String },
282}
283
284impl Action {
285    fn apply(self, registry: &SessionsRegistry) {
286        match self {
287            Self::Seen {
288                id,
289                cwd,
290                transcript_path,
291            } => registry.observe(ObserveRequest {
292                agent_id: None,
293                pid: None,
294                agent: Agent::Codex,
295                session_id: id,
296                cwd,
297                transcript_path: Some(transcript_path),
298                // Keeps the current state (a new session starts idle), so the
299                // watcher never overrides what the hooks reported.
300                event: SessionEvent::TranscriptDiscovered,
301                repo: None,
302                model: None,
303            }),
304            Self::End { id } => {
305                registry.end(&id, Some("codex rollout watcher"), None);
306            }
307        }
308    }
309}
310
311/// Whether `modified` is within [`RECENT_ACTIVITY_WINDOW`] of `now` (a future
312/// mtime counts as recent).
313fn is_recent(modified: SystemTime, now: SystemTime) -> bool {
314    now.duration_since(modified)
315        .map_or(true, |elapsed| elapsed <= RECENT_ACTIVITY_WINDOW)
316}
317
318/// Every `rollout-*.jsonl` under `root/YYYY/MM/DD/`, with its size and mtime.
319fn list_rollouts(root: &Path) -> Vec<(PathBuf, u64, SystemTime)> {
320    let mut out = Vec::new();
321    let subdirs = |dir: &Path| -> Vec<PathBuf> {
322        std::fs::read_dir(dir)
323            .map(|entries| {
324                entries
325                    .flatten()
326                    .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
327                    .map(|e| e.path())
328                    .collect()
329            })
330            .unwrap_or_default()
331    };
332    for year in subdirs(root) {
333        for month in subdirs(&year) {
334            for day in subdirs(&month) {
335                let Ok(files) = std::fs::read_dir(&day) else {
336                    continue;
337                };
338                for file in files.flatten() {
339                    let path = file.path();
340                    let is_rollout = path.extension().is_some_and(|e| e == "jsonl")
341                        && path
342                            .file_name()
343                            .and_then(|n| n.to_str())
344                            .is_some_and(|n| n.starts_with("rollout-"));
345                    if !is_rollout {
346                        continue;
347                    }
348                    let Ok(meta) = file.metadata() else {
349                        continue;
350                    };
351                    let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
352                    out.push((path, meta.len(), modified));
353                }
354            }
355        }
356    }
357    out
358}
359
360/// Scans `sessions` (and probes locks under `locks`), updating `state` and
361/// returning what the registry should hear. `probe` is injected so tests can
362/// script lock states. Pure apart from `state` and the file reads.
363fn scan(
364    sessions: &Path,
365    locks: &Path,
366    state: &mut ScanState,
367    now: SystemTime,
368    probe: &dyn Fn(&Path, &str) -> LockState,
369) -> Vec<Action> {
370    let mut actions = Vec::new();
371    let mut present = HashSet::new();
372    for (path, size, modified) in list_rollouts(sessions) {
373        present.insert(path.clone());
374        let recent = is_recent(modified, now);
375        let tracked = match state.remove(&path) {
376            None if !recent => unread_or_held(&path, size, None, locks, now, probe, &mut actions),
377            // First sight of a recent rollout, or growth of one not read yet.
378            None => first_read(&path, size, locks, now, probe, &mut actions),
379            Some(Tracked::Unread { size: old, .. }) if size != old && recent => {
380                first_read(&path, size, locks, now, probe, &mut actions)
381            }
382            Some(Tracked::Unread { probed, .. }) => {
383                unread_or_held(&path, size, Some(probed), locks, now, probe, &mut actions)
384            }
385            Some(Tracked::Skipped) => Tracked::Skipped,
386            Some(Tracked::Session(track)) => {
387                Tracked::Session(rescan(track, &path, size, locks, now, probe, &mut actions))
388            }
389        };
390        state.insert(path, tracked);
391    }
392    // A rollout that left the tree was archived (moved to `archived_sessions/`)
393    // or deleted: either way the session is over.
394    state.retain(|path, tracked| {
395        if present.contains(path) {
396            return true;
397        }
398        if let Tracked::Session(track) = tracked {
399            if !track.ended {
400                actions.push(Action::End {
401                    id: track.id.clone(),
402                });
403            }
404        }
405        false
406    });
407    actions
408}
409
410/// The thread id a rollout's file name ends in: `rollout-<timestamp>-<id>.jsonl`,
411/// where the id is a UUID. `None` for a name that does not end in one.
412fn thread_id_from_file_name(path: &Path) -> Option<&str> {
413    let stem = path.file_stem()?.to_str()?;
414    let split = stem.len().checked_sub(36)?;
415    let id = stem.get(split..)?;
416    let separated = split
417        .checked_sub(1)
418        .is_some_and(|dash| stem.as_bytes().get(dash) == Some(&b'-'));
419    (separated && is_session_uuid(id)).then_some(id)
420}
421
422/// Handles a rollout that is not recent and has not been read: it stays
423/// [`Tracked::Unread`] unless its thread's lock is held, in which case a live
424/// process has it loaded and it is read now, whatever its mtime (#2108). `probed`
425/// is when the lock was last looked at, if it has been: it is not looked at again
426/// before [`UNREAD_PROBE_INTERVAL`] has passed.
427fn unread_or_held(
428    path: &Path,
429    size: u64,
430    probed: Option<SystemTime>,
431    locks: &Path,
432    now: SystemTime,
433    probe: &dyn Fn(&Path, &str) -> LockState,
434    actions: &mut Vec<Action>,
435) -> Tracked {
436    if let Some(last) = probed {
437        if now.duration_since(last).unwrap_or_default() < UNREAD_PROBE_INTERVAL {
438            return Tracked::Unread { size, probed: last };
439        }
440    }
441    match thread_id_from_file_name(path) {
442        Some(id) if probe(locks, id) == LockState::Held => {
443            first_read(path, size, locks, now, probe, actions)
444        }
445        _ => Tracked::Unread { size, probed: now },
446    }
447}
448
449/// Reads a rollout's head for the first time, announcing it when it is a
450/// session whose thread is not known to be gone.
451fn first_read(
452    path: &Path,
453    size: u64,
454    locks: &Path,
455    now: SystemTime,
456    probe: &dyn Fn(&Path, &str) -> LockState,
457    actions: &mut Vec<Action>,
458) -> Tracked {
459    let (id, cwd) = match read_head(path) {
460        Head::Skip => return Tracked::Skipped,
461        Head::Incomplete => return Tracked::Unread { size, probed: now },
462        Head::Session { id, cwd } => (id, cwd),
463    };
464    let lock = probe(locks, &id);
465    // A free or missing lock means the thread already ended (the daemon started
466    // after it did): record it, so later growth revives it, but do not list it.
467    let gone = matches!(lock, LockState::Free | LockState::Absent);
468    if !gone {
469        actions.push(Action::Seen {
470            id: id.clone(),
471            cwd: cwd.clone(),
472            transcript_path: path.to_path_buf(),
473        });
474    }
475    Tracked::Session(SessionTrack {
476        id,
477        cwd,
478        size,
479        lock_seen: lock == LockState::Held,
480        ended: gone,
481        last_report: now,
482    })
483}
484
485/// Re-examines a known session: growth revives an ended one, a lock seen held
486/// and now released ends it, and a live one is heartbeated.
487fn rescan(
488    mut track: SessionTrack,
489    path: &Path,
490    size: u64,
491    locks: &Path,
492    now: SystemTime,
493    probe: &dyn Fn(&Path, &str) -> LockState,
494    actions: &mut Vec<Action>,
495) -> SessionTrack {
496    let grew = size > track.size;
497    track.size = size;
498    if track.ended {
499        if !grew {
500            return track;
501        }
502        // Written to again after we ended it (`codex resume`): start over, and
503        // report it this scan rather than a heartbeat interval from now.
504        track.ended = false;
505        track.lock_seen = false;
506        track.last_report = SystemTime::UNIX_EPOCH;
507    }
508    let lock = probe(locks, &track.id);
509    if lock == LockState::Held {
510        track.lock_seen = true;
511    }
512    if track.lock_seen && matches!(lock, LockState::Free | LockState::Absent) {
513        track.ended = true;
514        actions.push(Action::End {
515            id: track.id.clone(),
516        });
517        return track;
518    }
519    let alive = grew || lock == LockState::Held;
520    let due = now
521        .duration_since(track.last_report)
522        .is_ok_and(|since| since >= HEARTBEAT_INTERVAL);
523    if alive && due {
524        track.last_report = now;
525        actions.push(Action::Seen {
526            id: track.id.clone(),
527            cwd: track.cwd.clone(),
528            transcript_path: path.to_path_buf(),
529        });
530    }
531    track
532}
533
534/// Spawns the watcher loop, returning its [`JoinHandle`].
535///
536/// Rescans every [`WATCH_INTERVAL`] and applies each action to `registry`,
537/// until `token` is cancelled. Parks idle when no Codex home can be resolved.
538/// Must be called from within a tokio runtime.
539pub fn spawn(registry: Arc<SessionsRegistry>, token: CancellationToken) -> JoinHandle<()> {
540    tokio::spawn(async move {
541        let Some(home) = codex_home() else {
542            tracing::debug!("no Codex home; Codex rollout watcher idle");
543            token.cancelled().await;
544            return;
545        };
546        let sessions = home.join("sessions");
547        let locks = home.join("thread-writer-locks");
548        tracing::debug!("Codex rollout watcher scanning {}", sessions.display());
549        let mut state = ScanState::new();
550        loop {
551            let (scan_sessions, scan_locks) = (sessions.clone(), locks.clone());
552            // Directory walks, head reads and lock probes are blocking I/O.
553            let mut owned = std::mem::take(&mut state);
554            let (returned, actions) = tokio::task::spawn_blocking(move || {
555                let actions = scan(
556                    &scan_sessions,
557                    &scan_locks,
558                    &mut owned,
559                    SystemTime::now(),
560                    &probe_lock,
561                );
562                (owned, actions)
563            })
564            .await
565            .unwrap_or_else(|_| (ScanState::new(), Vec::new()));
566            state = returned;
567            for action in actions {
568                action.apply(&registry);
569            }
570            tokio::select! {
571                () = token.cancelled() => break,
572                () = tokio::time::sleep(WATCH_INTERVAL) => {}
573            }
574        }
575    })
576}
577
578#[cfg(test)]
579#[allow(clippy::unwrap_used, clippy::expect_used)]
580mod tests {
581    use super::*;
582    use std::cell::RefCell;
583    use std::io::Write;
584
585    use crate::sessions::SessionState;
586
587    const ID: &str = "019a0000-0000-7000-8000-00000000c0de";
588
589    fn head(id: &str, source: serde_json::Value) -> String {
590        serde_json::json!({
591            "timestamp": "2026-09-25T00:00:00Z",
592            "type": "session_meta",
593            "payload": {
594                "id": id,
595                "session_id": id,
596                "cwd": "/work/repo",
597                "originator": "codex-tui",
598                "source": source,
599                "base_instructions": { "text": "x".repeat(2048) },
600            },
601        })
602        .to_string()
603    }
604
605    /// Writes `root/2026/09/25/rollout-…-<id>.jsonl` with `lines`.
606    fn write_rollout(root: &Path, id: &str, lines: &[String]) -> PathBuf {
607        let dir = root.join("2026").join("09").join("25");
608        std::fs::create_dir_all(&dir).unwrap();
609        let path = dir.join(format!("rollout-2026-09-25T00-00-00-{id}.jsonl"));
610        let mut f = std::fs::File::create(&path).unwrap();
611        for line in lines {
612            writeln!(f, "{line}").unwrap();
613        }
614        path
615    }
616
617    fn append(path: &Path, line: &str) {
618        let mut f = std::fs::OpenOptions::new().append(true).open(path).unwrap();
619        writeln!(f, "{line}").unwrap();
620    }
621
622    /// A scripted lock probe: returns whatever the cell currently holds.
623    fn scripted(cell: &RefCell<LockState>) -> impl Fn(&Path, &str) -> LockState + '_ {
624        move |_, _| *cell.borrow()
625    }
626
627    fn ids(actions: &[Action]) -> Vec<String> {
628        actions
629            .iter()
630            .map(|a| match a {
631                Action::Seen { id, .. } => format!("seen:{id}"),
632                Action::End { id } => format!("end:{id}"),
633            })
634            .collect()
635    }
636
637    #[test]
638    fn parse_head_reads_a_session_and_skips_subagents_and_junk() {
639        assert_eq!(
640            parse_head(&head(ID, serde_json::json!("cli"))),
641            Head::Session {
642                id: ID.to_string(),
643                cwd: Some(PathBuf::from("/work/repo")),
644            }
645        );
646        let subagent = serde_json::json!({ "subagent": { "thread_spawn": {} } });
647        assert_eq!(parse_head(&head(ID, subagent)), Head::Skip);
648        assert_eq!(parse_head("not json"), Head::Skip);
649        assert_eq!(
650            parse_head(r#"{"type":"response_item","payload":{"id":"x"}}"#),
651            Head::Skip
652        );
653        assert_eq!(
654            parse_head(r#"{"type":"session_meta","payload":{"cwd":"/w"}}"#),
655            Head::Skip
656        );
657        // `session_id` stands in when `id` is absent.
658        assert_eq!(
659            parse_head(r#"{"type":"session_meta","payload":{"session_id":"s"}}"#),
660            Head::Session {
661                id: "s".to_string(),
662                cwd: None
663            }
664        );
665    }
666
667    #[test]
668    fn read_head_needs_a_complete_first_line() {
669        let tmp = tempfile::tempdir().unwrap();
670        let path = tmp.path().join("r.jsonl");
671        std::fs::write(&path, head(ID, serde_json::json!("vscode"))).unwrap();
672        // No trailing newline yet: Codex is mid-write, so it is read later.
673        assert_eq!(read_head(&path), Head::Incomplete);
674        std::fs::write(&path, "").unwrap();
675        assert_eq!(read_head(&path), Head::Incomplete);
676        std::fs::write(
677            &path,
678            format!("{}\n{{}}\n", head(ID, serde_json::json!("vscode"))),
679        )
680        .unwrap();
681        assert!(matches!(read_head(&path), Head::Session { .. }));
682        assert_eq!(read_head(&tmp.path().join("missing")), Head::Skip);
683    }
684
685    #[test]
686    fn a_recent_rollout_is_discovered_once_with_its_cwd() {
687        let tmp = tempfile::tempdir().unwrap();
688        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
689        let lock = RefCell::new(LockState::Unknown);
690        let mut state = ScanState::new();
691        let now = SystemTime::now();
692        let first = scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock));
693        assert_eq!(
694            first,
695            vec![Action::Seen {
696                id: ID.to_string(),
697                cwd: Some(PathBuf::from("/work/repo")),
698                transcript_path: path,
699            }]
700        );
701        // Nothing new, nothing due: silence.
702        assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
703    }
704
705    #[test]
706    fn subagent_rollouts_and_non_rollout_files_are_never_reported() {
707        let tmp = tempfile::tempdir().unwrap();
708        let sub = serde_json::json!({ "subagent": "review" });
709        write_rollout(tmp.path(), "child", &[head("child", sub)]);
710        let dir = tmp.path().join("2026/09/25");
711        std::fs::write(dir.join("notes.jsonl"), "{}\n").unwrap();
712        std::fs::write(tmp.path().join("rollout-loose.jsonl"), "{}\n").unwrap();
713        let lock = RefCell::new(LockState::Held);
714        let mut state = ScanState::new();
715        let now = SystemTime::now();
716        assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
717        let later = now + HEARTBEAT_INTERVAL * 2;
718        assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
719    }
720
721    #[test]
722    fn an_old_rollout_is_recorded_silently_and_read_when_it_grows() {
723        let tmp = tempfile::tempdir().unwrap();
724        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
725        let lock = RefCell::new(LockState::Unknown);
726        let mut state = ScanState::new();
727        // Seen from far in the future, the file is not recent.
728        let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
729        assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
730        assert!(matches!(state[&path], Tracked::Unread { .. }));
731        // Resumed: it grows and is recent again, so its head is read now.
732        append(&path, "{}");
733        let now = SystemTime::now();
734        assert_eq!(
735            ids(&scan(
736                tmp.path(),
737                tmp.path(),
738                &mut state,
739                now,
740                &scripted(&lock)
741            )),
742            vec![format!("seen:{ID}")]
743        );
744    }
745
746    #[test]
747    fn an_old_rollout_whose_lock_is_held_is_announced_regardless_of_its_age() {
748        // #2108: a Codex started before hooks were installed, idle for hours,
749        // still holds its thread lock while it lives.
750        let tmp = tempfile::tempdir().unwrap();
751        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
752        let lock = RefCell::new(LockState::Held);
753        let mut state = ScanState::new();
754        let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
755        assert_eq!(
756            scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)),
757            vec![Action::Seen {
758                id: ID.to_string(),
759                cwd: Some(PathBuf::from("/work/repo")),
760                transcript_path: path.clone(),
761            }]
762        );
763        assert!(matches!(state[&path], Tracked::Session(_)));
764        // Tracked now: held and not yet due is quiet, and the release ends it.
765        assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
766        *lock.borrow_mut() = LockState::Free;
767        assert_eq!(
768            ids(&scan(
769                tmp.path(),
770                tmp.path(),
771                &mut state,
772                far,
773                &scripted(&lock)
774            )),
775            vec![format!("end:{ID}")]
776        );
777    }
778
779    #[test]
780    fn an_old_rollout_is_announced_when_its_lock_becomes_held_later() {
781        // A `codex resume` that has not written yet: still `Unread`, then the
782        // lock appears and a later probe announces it without any growth.
783        let tmp = tempfile::tempdir().unwrap();
784        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
785        let lock = RefCell::new(LockState::Absent);
786        let mut state = ScanState::new();
787        let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
788        assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
789        assert!(matches!(state[&path], Tracked::Unread { .. }));
790        *lock.borrow_mut() = LockState::Held;
791        assert_eq!(
792            ids(&scan(
793                tmp.path(),
794                tmp.path(),
795                &mut state,
796                far + UNREAD_PROBE_INTERVAL,
797                &scripted(&lock)
798            )),
799            vec![format!("seen:{ID}")]
800        );
801    }
802
803    #[test]
804    fn unread_rollouts_are_probed_once_per_interval_not_every_scan() {
805        // A Codex home holds thousands of old rollouts; probing each on every
806        // 5s scan would be thousands of syscalls for the daemon's whole life.
807        let tmp = tempfile::tempdir().unwrap();
808        write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
809        let probes = std::cell::Cell::new(0_u32);
810        let counting = |_: &Path, _: &str| {
811            probes.set(probes.get() + 1);
812            LockState::Absent
813        };
814        let mut state = ScanState::new();
815        let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
816        scan(tmp.path(), tmp.path(), &mut state, far, &counting);
817        assert_eq!(probes.get(), 1, "probed on first sight");
818        for scans in 1..=10 {
819            scan(
820                tmp.path(),
821                tmp.path(),
822                &mut state,
823                far + WATCH_INTERVAL * scans,
824                &counting,
825            );
826        }
827        assert_eq!(probes.get(), 1, "not again within the interval");
828        scan(
829            tmp.path(),
830            tmp.path(),
831            &mut state,
832            far + UNREAD_PROBE_INTERVAL,
833            &counting,
834        );
835        assert_eq!(probes.get(), 2, "once the interval has passed");
836    }
837
838    #[test]
839    fn an_old_rollout_with_a_free_or_unknown_lock_stays_unread() {
840        let tmp = tempfile::tempdir().unwrap();
841        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
842        let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
843        for held in [LockState::Free, LockState::Absent, LockState::Unknown] {
844            let lock = RefCell::new(held);
845            let mut state = ScanState::new();
846            assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
847            assert!(matches!(state[&path], Tracked::Unread { .. }), "{held:?}");
848        }
849    }
850
851    #[test]
852    fn an_old_subagent_rollout_with_a_held_lock_is_skipped_not_announced() {
853        let tmp = tempfile::tempdir().unwrap();
854        let sub = serde_json::json!({ "subagent": "review" });
855        let path = write_rollout(tmp.path(), ID, &[head(ID, sub)]);
856        let lock = RefCell::new(LockState::Held);
857        let mut state = ScanState::new();
858        let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
859        assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
860        assert!(matches!(state[&path], Tracked::Skipped));
861    }
862
863    #[test]
864    fn thread_ids_come_only_from_a_uuid_ending_the_file_name() {
865        let named = |name: &str| thread_id_from_file_name(Path::new(name)).map(str::to_string);
866        assert_eq!(
867            named(&format!("rollout-2026-09-25T00-00-00-{ID}.jsonl")),
868            Some(ID.to_string())
869        );
870        // No UUID, a short name, a non-hex group, or no separating dash.
871        assert_eq!(named("rollout-2026-09-25T00-00-00-child.jsonl"), None);
872        assert_eq!(named("x.jsonl"), None);
873        assert_eq!(
874            named("rollout-019a0000-0000-7000-8000-00000000zzzz.jsonl"),
875            None
876        );
877        assert_eq!(named(&format!("rollout{ID}.jsonl")), None);
878        assert_eq!(named(&format!("{ID}.jsonl")), None);
879    }
880
881    #[test]
882    fn a_held_lock_heartbeats_and_its_release_ends_the_session() {
883        let tmp = tempfile::tempdir().unwrap();
884        write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
885        let lock = RefCell::new(LockState::Held);
886        let mut state = ScanState::new();
887        let t0 = SystemTime::now();
888        assert_eq!(
889            scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock)).len(),
890            1
891        );
892        // Held, not yet due: quiet. Held and due: a heartbeat.
893        let t1 = t0 + WATCH_INTERVAL;
894        assert!(scan(tmp.path(), tmp.path(), &mut state, t1, &scripted(&lock)).is_empty());
895        let t2 = t0 + HEARTBEAT_INTERVAL;
896        assert_eq!(
897            ids(&scan(
898                tmp.path(),
899                tmp.path(),
900                &mut state,
901                t2,
902                &scripted(&lock)
903            )),
904            vec![format!("seen:{ID}")]
905        );
906        // The process was killed: the kernel dropped its lock.
907        *lock.borrow_mut() = LockState::Free;
908        assert_eq!(
909            ids(&scan(
910                tmp.path(),
911                tmp.path(),
912                &mut state,
913                t2,
914                &scripted(&lock)
915            )),
916            vec![format!("end:{ID}")]
917        );
918        // Ended once, not again.
919        let t3 = t2 + HEARTBEAT_INTERVAL;
920        assert!(scan(tmp.path(), tmp.path(), &mut state, t3, &scripted(&lock)).is_empty());
921    }
922
923    #[test]
924    fn a_lock_never_seen_held_does_not_end_the_session() {
925        // An older Codex without a lock directory probes `Unknown`.
926        let tmp = tempfile::tempdir().unwrap();
927        write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
928        let lock = RefCell::new(LockState::Unknown);
929        let mut state = ScanState::new();
930        let t0 = SystemTime::now();
931        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
932        let later = t0 + HEARTBEAT_INTERVAL * 3;
933        assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
934        // An unknown probe after a held one is not an end either.
935        *lock.borrow_mut() = LockState::Held;
936        scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock));
937        *lock.borrow_mut() = LockState::Unknown;
938        let actions = scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock));
939        assert!(!ids(&actions).iter().any(|a| a.starts_with("end:")));
940    }
941
942    #[test]
943    fn growth_without_a_lock_is_a_heartbeat_not_a_state() {
944        let tmp = tempfile::tempdir().unwrap();
945        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("exec"))]);
946        let lock = RefCell::new(LockState::Unknown);
947        let mut state = ScanState::new();
948        let t0 = SystemTime::now();
949        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
950        append(&path, "{}");
951        let due = t0 + HEARTBEAT_INTERVAL;
952        assert_eq!(
953            ids(&scan(
954                tmp.path(),
955                tmp.path(),
956                &mut state,
957                due,
958                &scripted(&lock)
959            )),
960            vec![format!("seen:{ID}")]
961        );
962    }
963
964    #[test]
965    fn an_archived_rollout_ends_its_session_once() {
966        let tmp = tempfile::tempdir().unwrap();
967        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("vscode"))]);
968        let lock = RefCell::new(LockState::Held);
969        let mut state = ScanState::new();
970        let now = SystemTime::now();
971        scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock));
972        // Archiving moves the file out of the watched tree.
973        let archived = tmp.path().join("archived.jsonl");
974        std::fs::rename(&path, archived).unwrap();
975        assert_eq!(
976            ids(&scan(
977                tmp.path(),
978                tmp.path(),
979                &mut state,
980                now,
981                &scripted(&lock)
982            )),
983            vec![format!("end:{ID}")]
984        );
985        assert!(state.is_empty());
986        assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
987    }
988
989    #[test]
990    fn a_resumed_session_is_reported_again_after_an_end() {
991        let tmp = tempfile::tempdir().unwrap();
992        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
993        let lock = RefCell::new(LockState::Held);
994        let mut state = ScanState::new();
995        let t0 = SystemTime::now();
996        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
997        *lock.borrow_mut() = LockState::Absent;
998        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
999        // `codex resume <id>` appends to the same rollout under the same id.
1000        *lock.borrow_mut() = LockState::Held;
1001        append(&path, "{}");
1002        let due = t0 + HEARTBEAT_INTERVAL;
1003        assert_eq!(
1004            ids(&scan(
1005                tmp.path(),
1006                tmp.path(),
1007                &mut state,
1008                due,
1009                &scripted(&lock)
1010            )),
1011            vec![format!("seen:{ID}")]
1012        );
1013    }
1014
1015    #[test]
1016    fn a_head_still_being_written_is_read_once_it_is_complete() {
1017        let tmp = tempfile::tempdir().unwrap();
1018        let dir = tmp.path().join("2026/09/25");
1019        std::fs::create_dir_all(&dir).unwrap();
1020        let path = dir.join(format!("rollout-2026-09-25T00-00-00-{ID}.jsonl"));
1021        let full = head(ID, serde_json::json!("cli"));
1022        std::fs::write(&path, &full[..full.len() / 2]).unwrap();
1023        let lock = RefCell::new(LockState::Held);
1024        let mut state = ScanState::new();
1025        let now = SystemTime::now();
1026        assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
1027        assert!(matches!(state[&path], Tracked::Unread { .. }));
1028        std::fs::write(&path, format!("{full}\n")).unwrap();
1029        assert_eq!(
1030            ids(&scan(
1031                tmp.path(),
1032                tmp.path(),
1033                &mut state,
1034                now,
1035                &scripted(&lock)
1036            )),
1037            vec![format!("seen:{ID}")]
1038        );
1039    }
1040
1041    #[test]
1042    fn a_restart_does_not_list_a_session_whose_lock_is_already_released() {
1043        for gone in [LockState::Free, LockState::Absent] {
1044            let tmp = tempfile::tempdir().unwrap();
1045            let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
1046            let lock = RefCell::new(gone);
1047            let mut state = ScanState::new();
1048            let now = SystemTime::now();
1049            assert!(
1050                scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty(),
1051                "{gone:?}"
1052            );
1053            // Not listed, not ended: nothing to say until it is written to again.
1054            let later = now + HEARTBEAT_INTERVAL * 2;
1055            assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
1056            // `codex resume` reloads it and appends: reported straight away.
1057            *lock.borrow_mut() = LockState::Held;
1058            append(&path, "{}");
1059            assert_eq!(
1060                ids(&scan(
1061                    tmp.path(),
1062                    tmp.path(),
1063                    &mut state,
1064                    later,
1065                    &scripted(&lock)
1066                )),
1067                vec![format!("seen:{ID}")]
1068            );
1069        }
1070    }
1071
1072    #[test]
1073    fn scanning_a_missing_root_is_empty() {
1074        let lock = RefCell::new(LockState::Unknown);
1075        let mut state = ScanState::new();
1076        let missing = Path::new("/nonexistent/codex/sessions");
1077        assert!(scan(
1078            missing,
1079            missing,
1080            &mut state,
1081            SystemTime::now(),
1082            &scripted(&lock)
1083        )
1084        .is_empty());
1085    }
1086
1087    #[cfg(unix)]
1088    #[test]
1089    fn probe_lock_tells_held_from_free_and_absent() {
1090        use nix::fcntl::{Flock, FlockArg};
1091
1092        let tmp = tempfile::tempdir().unwrap();
1093        // No lock directory at all: a Codex without locks, so no information.
1094        assert_eq!(
1095            probe_lock(&tmp.path().join("missing"), ID),
1096            LockState::Unknown
1097        );
1098        assert_eq!(probe_lock(tmp.path(), ID), LockState::Absent);
1099        let path = tmp.path().join(format!("{ID}.lock"));
1100        std::fs::write(&path, "").unwrap();
1101        assert_eq!(probe_lock(tmp.path(), ID), LockState::Free);
1102        // Codex holds its lock exclusively, from another open file description.
1103        let holder = Flock::lock(
1104            std::fs::File::open(&path).unwrap(),
1105            FlockArg::LockExclusiveNonblock,
1106        )
1107        .unwrap();
1108        assert_eq!(probe_lock(tmp.path(), ID), LockState::Held);
1109        // The probe did not disturb the holder: it still holds the lock.
1110        assert_eq!(probe_lock(tmp.path(), ID), LockState::Held);
1111        drop(holder);
1112        assert_eq!(probe_lock(tmp.path(), ID), LockState::Free);
1113    }
1114
1115    #[test]
1116    fn the_thread_lock_probe_reads_the_resolved_homes_lock_directory() {
1117        let tmp = tempfile::tempdir().unwrap();
1118        // No home resolves at all: no information.
1119        assert_eq!(probe_thread_lock_in(None, ID), LockState::Unknown);
1120        // A home without a lock directory is a Codex that keeps no locks.
1121        assert_eq!(
1122            probe_thread_lock_in(Some(tmp.path()), ID),
1123            LockState::Unknown
1124        );
1125        let locks = tmp.path().join("thread-writer-locks");
1126        std::fs::create_dir(&locks).unwrap();
1127        assert_eq!(
1128            probe_thread_lock_in(Some(tmp.path()), ID),
1129            LockState::Absent
1130        );
1131        std::fs::write(locks.join(format!("{ID}.lock")), "").unwrap();
1132        assert_eq!(probe_thread_lock_in(Some(tmp.path()), ID), LockState::Free);
1133    }
1134
1135    #[test]
1136    fn actions_apply_to_the_registry_as_codex_sessions() {
1137        let registry = SessionsRegistry::new();
1138        Action::Seen {
1139            id: ID.to_string(),
1140            cwd: Some(PathBuf::from("/work/repo")),
1141            transcript_path: PathBuf::from("/r.jsonl"),
1142        }
1143        .apply(&registry);
1144        let listed = registry.list();
1145        assert_eq!(listed.len(), 1);
1146        assert_eq!(listed[0].agent, Agent::Codex);
1147        assert_eq!(listed[0].state, SessionState::Idle);
1148        assert_eq!(listed[0].cwd.as_deref(), Some(Path::new("/work/repo")));
1149        Action::End { id: ID.to_string() }.apply(&registry);
1150        assert_eq!(registry.list()[0].state, SessionState::Ended);
1151    }
1152}