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
45use std::collections::{HashMap, HashSet};
46use std::io::{BufRead, BufReader, Read};
47use std::path::{Path, PathBuf};
48use std::sync::Arc;
49use std::time::{Duration, SystemTime};
50
51use serde::Deserialize;
52use tokio::task::JoinHandle;
53use tokio_util::sync::CancellationToken;
54
55use super::{Agent, ObserveRequest, SessionEvent, SessionsRegistry};
56
57/// Codex's home directory override, as Codex itself reads it.
58const CODEX_HOME_ENV: &str = "CODEX_HOME";
59
60/// How often the watcher rescans. Matches the Claude transcript watcher.
61const WATCH_INTERVAL: Duration = Duration::from_secs(5);
62
63/// A rollout must have been modified within this window to be announced when
64/// first seen, so a fresh daemon does not flood the registry with every
65/// historical session. Matches the registry's session TTL.
66const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(300);
67
68/// How often a live session (lock held, or rollout growing) is re-reported, to
69/// keep it inside the registry's 5-minute TTL without a report every scan.
70const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60);
71
72/// The most of a rollout's head line the watcher will read. `session_meta`
73/// embeds Codex's base instructions (~20 KiB today); a head without a newline
74/// within this bound is treated as unreadable.
75const MAX_HEAD_BYTES: u64 = 1024 * 1024;
76
77/// Where Codex keeps its state: `$CODEX_HOME`, else `~/.codex`.
78fn codex_home() -> Option<PathBuf> {
79    if let Some(dir) = std::env::var_os(CODEX_HOME_ENV).filter(|d| !d.is_empty()) {
80        return Some(PathBuf::from(dir));
81    }
82    dirs::home_dir().map(|home| home.join(".codex"))
83}
84
85/// The fields of a rollout's head line the watcher reads. Everything else,
86/// including the embedded instructions, is skipped by serde.
87#[derive(Debug, Deserialize)]
88struct HeadLine {
89    #[serde(rename = "type")]
90    kind: String,
91    #[serde(default)]
92    payload: Option<SessionMeta>,
93}
94
95/// The `session_meta` payload fields the watcher reads.
96#[derive(Debug, Deserialize)]
97struct SessionMeta {
98    /// The thread id — the hook `session_id`, the rollout filename's id and the
99    /// lock filename, for a non-subagent thread.
100    #[serde(default)]
101    id: Option<String>,
102    #[serde(default)]
103    session_id: Option<String>,
104    #[serde(default)]
105    cwd: Option<PathBuf>,
106    /// `"cli"`, `"vscode"`, `"exec"`, … for a session; an object
107    /// (`{"subagent": …}`) for a subagent thread.
108    #[serde(default)]
109    source: Option<serde_json::Value>,
110}
111
112/// What a rollout's head says about it.
113#[derive(Debug, Clone, PartialEq, Eq)]
114enum Head {
115    /// A top-level session.
116    Session { id: String, cwd: Option<PathBuf> },
117    /// A subagent thread, or a head the watcher cannot read — never reported.
118    Skip,
119    /// The first line is not complete yet (Codex is still writing it); read
120    /// again once the file grows.
121    Incomplete,
122}
123
124/// Parses a rollout head line. Schema-tolerant: anything but a `session_meta`
125/// line with an id is [`Head::Skip`].
126fn parse_head(line: &str) -> Head {
127    let Ok(head) = serde_json::from_str::<HeadLine>(line) else {
128        return Head::Skip;
129    };
130    let Some(meta) = head.payload.filter(|_| head.kind == "session_meta") else {
131        return Head::Skip;
132    };
133    if meta
134        .source
135        .as_ref()
136        .is_some_and(serde_json::Value::is_object)
137    {
138        return Head::Skip;
139    }
140    match meta
141        .id
142        .or(meta.session_id)
143        .filter(|id| !id.trim().is_empty())
144    {
145        Some(id) => Head::Session { id, cwd: meta.cwd },
146        None => Head::Skip,
147    }
148}
149
150/// Reads and parses the first line of `path`, bounded to [`MAX_HEAD_BYTES`].
151fn read_head(path: &Path) -> Head {
152    let Ok(file) = std::fs::File::open(path) else {
153        return Head::Skip;
154    };
155    let mut line = String::new();
156    let mut reader = BufReader::new(file.take(MAX_HEAD_BYTES));
157    match reader.read_line(&mut line) {
158        Ok(_) if line.ends_with('\n') => parse_head(&line),
159        // Hit the bound without a newline: not a head this watcher will read.
160        Ok(n) if n as u64 >= MAX_HEAD_BYTES => Head::Skip,
161        Ok(_) => Head::Incomplete,
162        Err(_) => Head::Skip,
163    }
164}
165
166/// The state of a thread's writer lock.
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168enum LockState {
169    /// Codex holds it: the thread is loaded in a live process.
170    Held,
171    /// The file exists and nobody holds it: the holder exited or was killed.
172    Free,
173    /// No lock file in an existing lock directory: the thread is not loaded.
174    Absent,
175    /// The probe failed for another reason; treated as no information.
176    Unknown,
177}
178
179/// Probes `<locks>/<id>.lock` with a non-blocking shared `flock`, released at
180/// once. See the module docs for why this cannot disturb a live holder.
181#[cfg(unix)]
182fn probe_lock(locks: &Path, id: &str) -> LockState {
183    use nix::errno::Errno;
184    use nix::fcntl::{Flock, FlockArg};
185
186    let file = match std::fs::File::open(locks.join(format!("{id}.lock"))) {
187        Ok(file) => file,
188        // Without the directory this Codex keeps no locks at all, which says
189        // nothing about the session.
190        Err(e) if e.kind() == std::io::ErrorKind::NotFound && locks.is_dir() => {
191            return LockState::Absent
192        }
193        Err(_) => return LockState::Unknown,
194    };
195    match Flock::lock(file, FlockArg::LockSharedNonblock) {
196        // Dropping the guard releases the shared lock straight away.
197        Ok(_guard) => LockState::Free,
198        Err((_, Errno::EWOULDBLOCK)) => LockState::Held,
199        Err(_) => LockState::Unknown,
200    }
201}
202
203#[cfg(not(unix))]
204fn probe_lock(_locks: &Path, _id: &str) -> LockState {
205    LockState::Unknown
206}
207
208/// The watcher's record of one rollout file.
209#[derive(Debug, Clone)]
210enum Tracked {
211    /// Seen but not read: it was not recently active when first seen. Its head
212    /// is read if it later grows.
213    Unread { size: u64 },
214    /// A subagent or unreadable rollout; never reported.
215    Skipped,
216    /// A session rollout.
217    Session(SessionTrack),
218}
219
220/// Per-session bookkeeping for a [`Tracked::Session`].
221#[derive(Debug, Clone)]
222struct SessionTrack {
223    id: String,
224    cwd: Option<PathBuf>,
225    size: u64,
226    /// Whether this watcher has seen the thread's lock held. Only then does a
227    /// free or missing lock mean the process is gone — a Codex without locks,
228    /// or a thread that was never loaded, must not read as ended.
229    lock_seen: bool,
230    /// Whether this watcher ended the session (it stops reporting it until the
231    /// rollout grows again, e.g. `codex resume`).
232    ended: bool,
233    /// When the session was last reported.
234    last_report: SystemTime,
235}
236
237/// The watcher's state across scans, keyed by rollout path.
238type ScanState = HashMap<PathBuf, Tracked>;
239
240/// One thing a scan asks the registry to do.
241#[derive(Debug, Clone, PartialEq, Eq)]
242enum Action {
243    /// Report the session as present, keeping its current state.
244    Seen {
245        id: String,
246        cwd: Option<PathBuf>,
247        transcript_path: PathBuf,
248    },
249    /// End the session.
250    End { id: String },
251}
252
253impl Action {
254    fn apply(self, registry: &SessionsRegistry) {
255        match self {
256            Self::Seen {
257                id,
258                cwd,
259                transcript_path,
260            } => registry.observe(ObserveRequest {
261                pid: None,
262                agent: Agent::Codex,
263                session_id: id,
264                cwd,
265                transcript_path: Some(transcript_path),
266                // Keeps the current state (a new session starts idle), so the
267                // watcher never overrides what the hooks reported.
268                event: SessionEvent::TranscriptDiscovered,
269                repo: None,
270                model: None,
271            }),
272            Self::End { id } => {
273                registry.end(&id, Some("codex rollout watcher"), None);
274            }
275        }
276    }
277}
278
279/// Whether `modified` is within [`RECENT_ACTIVITY_WINDOW`] of `now` (a future
280/// mtime counts as recent).
281fn is_recent(modified: SystemTime, now: SystemTime) -> bool {
282    now.duration_since(modified)
283        .map_or(true, |elapsed| elapsed <= RECENT_ACTIVITY_WINDOW)
284}
285
286/// Every `rollout-*.jsonl` under `root/YYYY/MM/DD/`, with its size and mtime.
287fn list_rollouts(root: &Path) -> Vec<(PathBuf, u64, SystemTime)> {
288    let mut out = Vec::new();
289    let subdirs = |dir: &Path| -> Vec<PathBuf> {
290        std::fs::read_dir(dir)
291            .map(|entries| {
292                entries
293                    .flatten()
294                    .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
295                    .map(|e| e.path())
296                    .collect()
297            })
298            .unwrap_or_default()
299    };
300    for year in subdirs(root) {
301        for month in subdirs(&year) {
302            for day in subdirs(&month) {
303                let Ok(files) = std::fs::read_dir(&day) else {
304                    continue;
305                };
306                for file in files.flatten() {
307                    let path = file.path();
308                    let is_rollout = path.extension().is_some_and(|e| e == "jsonl")
309                        && path
310                            .file_name()
311                            .and_then(|n| n.to_str())
312                            .is_some_and(|n| n.starts_with("rollout-"));
313                    if !is_rollout {
314                        continue;
315                    }
316                    let Ok(meta) = file.metadata() else {
317                        continue;
318                    };
319                    let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
320                    out.push((path, meta.len(), modified));
321                }
322            }
323        }
324    }
325    out
326}
327
328/// Scans `sessions` (and probes locks under `locks`), updating `state` and
329/// returning what the registry should hear. `probe` is injected so tests can
330/// script lock states. Pure apart from `state` and the file reads.
331fn scan(
332    sessions: &Path,
333    locks: &Path,
334    state: &mut ScanState,
335    now: SystemTime,
336    probe: &dyn Fn(&Path, &str) -> LockState,
337) -> Vec<Action> {
338    let mut actions = Vec::new();
339    let mut present = HashSet::new();
340    for (path, size, modified) in list_rollouts(sessions) {
341        present.insert(path.clone());
342        let recent = is_recent(modified, now);
343        let tracked = match state.remove(&path) {
344            None if !recent => Tracked::Unread { size },
345            // First sight of a recent rollout, or growth of one not read yet.
346            None => first_read(&path, size, locks, now, probe, &mut actions),
347            Some(Tracked::Unread { size: old }) if size != old && recent => {
348                first_read(&path, size, locks, now, probe, &mut actions)
349            }
350            Some(Tracked::Unread { .. }) => Tracked::Unread { size },
351            Some(Tracked::Skipped) => Tracked::Skipped,
352            Some(Tracked::Session(track)) => {
353                Tracked::Session(rescan(track, &path, size, locks, now, probe, &mut actions))
354            }
355        };
356        state.insert(path, tracked);
357    }
358    // A rollout that left the tree was archived (moved to `archived_sessions/`)
359    // or deleted: either way the session is over.
360    state.retain(|path, tracked| {
361        if present.contains(path) {
362            return true;
363        }
364        if let Tracked::Session(track) = tracked {
365            if !track.ended {
366                actions.push(Action::End {
367                    id: track.id.clone(),
368                });
369            }
370        }
371        false
372    });
373    actions
374}
375
376/// Reads a rollout's head for the first time, announcing it when it is a
377/// session whose thread is not known to be gone.
378fn first_read(
379    path: &Path,
380    size: u64,
381    locks: &Path,
382    now: SystemTime,
383    probe: &dyn Fn(&Path, &str) -> LockState,
384    actions: &mut Vec<Action>,
385) -> Tracked {
386    let (id, cwd) = match read_head(path) {
387        Head::Skip => return Tracked::Skipped,
388        Head::Incomplete => return Tracked::Unread { size },
389        Head::Session { id, cwd } => (id, cwd),
390    };
391    let lock = probe(locks, &id);
392    // A free or missing lock means the thread already ended (the daemon started
393    // after it did): record it, so later growth revives it, but do not list it.
394    let gone = matches!(lock, LockState::Free | LockState::Absent);
395    if !gone {
396        actions.push(Action::Seen {
397            id: id.clone(),
398            cwd: cwd.clone(),
399            transcript_path: path.to_path_buf(),
400        });
401    }
402    Tracked::Session(SessionTrack {
403        id,
404        cwd,
405        size,
406        lock_seen: lock == LockState::Held,
407        ended: gone,
408        last_report: now,
409    })
410}
411
412/// Re-examines a known session: growth revives an ended one, a lock seen held
413/// and now released ends it, and a live one is heartbeated.
414fn rescan(
415    mut track: SessionTrack,
416    path: &Path,
417    size: u64,
418    locks: &Path,
419    now: SystemTime,
420    probe: &dyn Fn(&Path, &str) -> LockState,
421    actions: &mut Vec<Action>,
422) -> SessionTrack {
423    let grew = size > track.size;
424    track.size = size;
425    if track.ended {
426        if !grew {
427            return track;
428        }
429        // Written to again after we ended it (`codex resume`): start over, and
430        // report it this scan rather than a heartbeat interval from now.
431        track.ended = false;
432        track.lock_seen = false;
433        track.last_report = SystemTime::UNIX_EPOCH;
434    }
435    let lock = probe(locks, &track.id);
436    if lock == LockState::Held {
437        track.lock_seen = true;
438    }
439    if track.lock_seen && matches!(lock, LockState::Free | LockState::Absent) {
440        track.ended = true;
441        actions.push(Action::End {
442            id: track.id.clone(),
443        });
444        return track;
445    }
446    let alive = grew || lock == LockState::Held;
447    let due = now
448        .duration_since(track.last_report)
449        .is_ok_and(|since| since >= HEARTBEAT_INTERVAL);
450    if alive && due {
451        track.last_report = now;
452        actions.push(Action::Seen {
453            id: track.id.clone(),
454            cwd: track.cwd.clone(),
455            transcript_path: path.to_path_buf(),
456        });
457    }
458    track
459}
460
461/// Spawns the watcher loop, returning its [`JoinHandle`].
462///
463/// Rescans every [`WATCH_INTERVAL`] and applies each action to `registry`,
464/// until `token` is cancelled. Parks idle when no Codex home can be resolved.
465/// Must be called from within a tokio runtime.
466pub fn spawn(registry: Arc<SessionsRegistry>, token: CancellationToken) -> JoinHandle<()> {
467    tokio::spawn(async move {
468        let Some(home) = codex_home() else {
469            tracing::debug!("no Codex home; Codex rollout watcher idle");
470            token.cancelled().await;
471            return;
472        };
473        let sessions = home.join("sessions");
474        let locks = home.join("thread-writer-locks");
475        tracing::debug!("Codex rollout watcher scanning {}", sessions.display());
476        let mut state = ScanState::new();
477        loop {
478            let (scan_sessions, scan_locks) = (sessions.clone(), locks.clone());
479            // Directory walks, head reads and lock probes are blocking I/O.
480            let mut owned = std::mem::take(&mut state);
481            let (returned, actions) = tokio::task::spawn_blocking(move || {
482                let actions = scan(
483                    &scan_sessions,
484                    &scan_locks,
485                    &mut owned,
486                    SystemTime::now(),
487                    &probe_lock,
488                );
489                (owned, actions)
490            })
491            .await
492            .unwrap_or_else(|_| (ScanState::new(), Vec::new()));
493            state = returned;
494            for action in actions {
495                action.apply(&registry);
496            }
497            tokio::select! {
498                () = token.cancelled() => break,
499                () = tokio::time::sleep(WATCH_INTERVAL) => {}
500            }
501        }
502    })
503}
504
505#[cfg(test)]
506#[allow(clippy::unwrap_used, clippy::expect_used)]
507mod tests {
508    use super::*;
509    use std::cell::RefCell;
510    use std::io::Write;
511
512    use crate::sessions::SessionState;
513
514    const ID: &str = "019a0000-0000-7000-8000-00000000c0de";
515
516    fn head(id: &str, source: serde_json::Value) -> String {
517        serde_json::json!({
518            "timestamp": "2026-09-25T00:00:00Z",
519            "type": "session_meta",
520            "payload": {
521                "id": id,
522                "session_id": id,
523                "cwd": "/work/repo",
524                "originator": "codex-tui",
525                "source": source,
526                "base_instructions": { "text": "x".repeat(2048) },
527            },
528        })
529        .to_string()
530    }
531
532    /// Writes `root/2026/09/25/rollout-…-<id>.jsonl` with `lines`.
533    fn write_rollout(root: &Path, id: &str, lines: &[String]) -> PathBuf {
534        let dir = root.join("2026").join("09").join("25");
535        std::fs::create_dir_all(&dir).unwrap();
536        let path = dir.join(format!("rollout-2026-09-25T00-00-00-{id}.jsonl"));
537        let mut f = std::fs::File::create(&path).unwrap();
538        for line in lines {
539            writeln!(f, "{line}").unwrap();
540        }
541        path
542    }
543
544    fn append(path: &Path, line: &str) {
545        let mut f = std::fs::OpenOptions::new().append(true).open(path).unwrap();
546        writeln!(f, "{line}").unwrap();
547    }
548
549    /// A scripted lock probe: returns whatever the cell currently holds.
550    fn scripted(cell: &RefCell<LockState>) -> impl Fn(&Path, &str) -> LockState + '_ {
551        move |_, _| *cell.borrow()
552    }
553
554    fn ids(actions: &[Action]) -> Vec<String> {
555        actions
556            .iter()
557            .map(|a| match a {
558                Action::Seen { id, .. } => format!("seen:{id}"),
559                Action::End { id } => format!("end:{id}"),
560            })
561            .collect()
562    }
563
564    #[test]
565    fn parse_head_reads_a_session_and_skips_subagents_and_junk() {
566        assert_eq!(
567            parse_head(&head(ID, serde_json::json!("cli"))),
568            Head::Session {
569                id: ID.to_string(),
570                cwd: Some(PathBuf::from("/work/repo")),
571            }
572        );
573        let subagent = serde_json::json!({ "subagent": { "thread_spawn": {} } });
574        assert_eq!(parse_head(&head(ID, subagent)), Head::Skip);
575        assert_eq!(parse_head("not json"), Head::Skip);
576        assert_eq!(
577            parse_head(r#"{"type":"response_item","payload":{"id":"x"}}"#),
578            Head::Skip
579        );
580        assert_eq!(
581            parse_head(r#"{"type":"session_meta","payload":{"cwd":"/w"}}"#),
582            Head::Skip
583        );
584        // `session_id` stands in when `id` is absent.
585        assert_eq!(
586            parse_head(r#"{"type":"session_meta","payload":{"session_id":"s"}}"#),
587            Head::Session {
588                id: "s".to_string(),
589                cwd: None
590            }
591        );
592    }
593
594    #[test]
595    fn read_head_needs_a_complete_first_line() {
596        let tmp = tempfile::tempdir().unwrap();
597        let path = tmp.path().join("r.jsonl");
598        std::fs::write(&path, head(ID, serde_json::json!("vscode"))).unwrap();
599        // No trailing newline yet: Codex is mid-write, so it is read later.
600        assert_eq!(read_head(&path), Head::Incomplete);
601        std::fs::write(&path, "").unwrap();
602        assert_eq!(read_head(&path), Head::Incomplete);
603        std::fs::write(
604            &path,
605            format!("{}\n{{}}\n", head(ID, serde_json::json!("vscode"))),
606        )
607        .unwrap();
608        assert!(matches!(read_head(&path), Head::Session { .. }));
609        assert_eq!(read_head(&tmp.path().join("missing")), Head::Skip);
610    }
611
612    #[test]
613    fn a_recent_rollout_is_discovered_once_with_its_cwd() {
614        let tmp = tempfile::tempdir().unwrap();
615        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
616        let lock = RefCell::new(LockState::Unknown);
617        let mut state = ScanState::new();
618        let now = SystemTime::now();
619        let first = scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock));
620        assert_eq!(
621            first,
622            vec![Action::Seen {
623                id: ID.to_string(),
624                cwd: Some(PathBuf::from("/work/repo")),
625                transcript_path: path,
626            }]
627        );
628        // Nothing new, nothing due: silence.
629        assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
630    }
631
632    #[test]
633    fn subagent_rollouts_and_non_rollout_files_are_never_reported() {
634        let tmp = tempfile::tempdir().unwrap();
635        let sub = serde_json::json!({ "subagent": "review" });
636        write_rollout(tmp.path(), "child", &[head("child", sub)]);
637        let dir = tmp.path().join("2026/09/25");
638        std::fs::write(dir.join("notes.jsonl"), "{}\n").unwrap();
639        std::fs::write(tmp.path().join("rollout-loose.jsonl"), "{}\n").unwrap();
640        let lock = RefCell::new(LockState::Held);
641        let mut state = ScanState::new();
642        let now = SystemTime::now();
643        assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
644        let later = now + HEARTBEAT_INTERVAL * 2;
645        assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
646    }
647
648    #[test]
649    fn an_old_rollout_is_recorded_silently_and_read_when_it_grows() {
650        let tmp = tempfile::tempdir().unwrap();
651        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
652        let lock = RefCell::new(LockState::Unknown);
653        let mut state = ScanState::new();
654        // Seen from far in the future, the file is not recent.
655        let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
656        assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
657        assert!(matches!(state[&path], Tracked::Unread { .. }));
658        // Resumed: it grows and is recent again, so its head is read now.
659        append(&path, "{}");
660        let now = SystemTime::now();
661        assert_eq!(
662            ids(&scan(
663                tmp.path(),
664                tmp.path(),
665                &mut state,
666                now,
667                &scripted(&lock)
668            )),
669            vec![format!("seen:{ID}")]
670        );
671    }
672
673    #[test]
674    fn a_held_lock_heartbeats_and_its_release_ends_the_session() {
675        let tmp = tempfile::tempdir().unwrap();
676        write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
677        let lock = RefCell::new(LockState::Held);
678        let mut state = ScanState::new();
679        let t0 = SystemTime::now();
680        assert_eq!(
681            scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock)).len(),
682            1
683        );
684        // Held, not yet due: quiet. Held and due: a heartbeat.
685        let t1 = t0 + WATCH_INTERVAL;
686        assert!(scan(tmp.path(), tmp.path(), &mut state, t1, &scripted(&lock)).is_empty());
687        let t2 = t0 + HEARTBEAT_INTERVAL;
688        assert_eq!(
689            ids(&scan(
690                tmp.path(),
691                tmp.path(),
692                &mut state,
693                t2,
694                &scripted(&lock)
695            )),
696            vec![format!("seen:{ID}")]
697        );
698        // The process was killed: the kernel dropped its lock.
699        *lock.borrow_mut() = LockState::Free;
700        assert_eq!(
701            ids(&scan(
702                tmp.path(),
703                tmp.path(),
704                &mut state,
705                t2,
706                &scripted(&lock)
707            )),
708            vec![format!("end:{ID}")]
709        );
710        // Ended once, not again.
711        let t3 = t2 + HEARTBEAT_INTERVAL;
712        assert!(scan(tmp.path(), tmp.path(), &mut state, t3, &scripted(&lock)).is_empty());
713    }
714
715    #[test]
716    fn a_lock_never_seen_held_does_not_end_the_session() {
717        // An older Codex without a lock directory probes `Unknown`.
718        let tmp = tempfile::tempdir().unwrap();
719        write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
720        let lock = RefCell::new(LockState::Unknown);
721        let mut state = ScanState::new();
722        let t0 = SystemTime::now();
723        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
724        let later = t0 + HEARTBEAT_INTERVAL * 3;
725        assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
726        // An unknown probe after a held one is not an end either.
727        *lock.borrow_mut() = LockState::Held;
728        scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock));
729        *lock.borrow_mut() = LockState::Unknown;
730        let actions = scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock));
731        assert!(!ids(&actions).iter().any(|a| a.starts_with("end:")));
732    }
733
734    #[test]
735    fn growth_without_a_lock_is_a_heartbeat_not_a_state() {
736        let tmp = tempfile::tempdir().unwrap();
737        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("exec"))]);
738        let lock = RefCell::new(LockState::Unknown);
739        let mut state = ScanState::new();
740        let t0 = SystemTime::now();
741        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
742        append(&path, "{}");
743        let due = t0 + HEARTBEAT_INTERVAL;
744        assert_eq!(
745            ids(&scan(
746                tmp.path(),
747                tmp.path(),
748                &mut state,
749                due,
750                &scripted(&lock)
751            )),
752            vec![format!("seen:{ID}")]
753        );
754    }
755
756    #[test]
757    fn an_archived_rollout_ends_its_session_once() {
758        let tmp = tempfile::tempdir().unwrap();
759        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("vscode"))]);
760        let lock = RefCell::new(LockState::Held);
761        let mut state = ScanState::new();
762        let now = SystemTime::now();
763        scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock));
764        // Archiving moves the file out of the watched tree.
765        let archived = tmp.path().join("archived.jsonl");
766        std::fs::rename(&path, archived).unwrap();
767        assert_eq!(
768            ids(&scan(
769                tmp.path(),
770                tmp.path(),
771                &mut state,
772                now,
773                &scripted(&lock)
774            )),
775            vec![format!("end:{ID}")]
776        );
777        assert!(state.is_empty());
778        assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
779    }
780
781    #[test]
782    fn a_resumed_session_is_reported_again_after_an_end() {
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::Held);
786        let mut state = ScanState::new();
787        let t0 = SystemTime::now();
788        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
789        *lock.borrow_mut() = LockState::Absent;
790        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
791        // `codex resume <id>` appends to the same rollout under the same id.
792        *lock.borrow_mut() = LockState::Held;
793        append(&path, "{}");
794        let due = t0 + HEARTBEAT_INTERVAL;
795        assert_eq!(
796            ids(&scan(
797                tmp.path(),
798                tmp.path(),
799                &mut state,
800                due,
801                &scripted(&lock)
802            )),
803            vec![format!("seen:{ID}")]
804        );
805    }
806
807    #[test]
808    fn a_head_still_being_written_is_read_once_it_is_complete() {
809        let tmp = tempfile::tempdir().unwrap();
810        let dir = tmp.path().join("2026/09/25");
811        std::fs::create_dir_all(&dir).unwrap();
812        let path = dir.join(format!("rollout-2026-09-25T00-00-00-{ID}.jsonl"));
813        let full = head(ID, serde_json::json!("cli"));
814        std::fs::write(&path, &full[..full.len() / 2]).unwrap();
815        let lock = RefCell::new(LockState::Held);
816        let mut state = ScanState::new();
817        let now = SystemTime::now();
818        assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
819        assert!(matches!(state[&path], Tracked::Unread { .. }));
820        std::fs::write(&path, format!("{full}\n")).unwrap();
821        assert_eq!(
822            ids(&scan(
823                tmp.path(),
824                tmp.path(),
825                &mut state,
826                now,
827                &scripted(&lock)
828            )),
829            vec![format!("seen:{ID}")]
830        );
831    }
832
833    #[test]
834    fn a_restart_does_not_list_a_session_whose_lock_is_already_released() {
835        for gone in [LockState::Free, LockState::Absent] {
836            let tmp = tempfile::tempdir().unwrap();
837            let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
838            let lock = RefCell::new(gone);
839            let mut state = ScanState::new();
840            let now = SystemTime::now();
841            assert!(
842                scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty(),
843                "{gone:?}"
844            );
845            // Not listed, not ended: nothing to say until it is written to again.
846            let later = now + HEARTBEAT_INTERVAL * 2;
847            assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
848            // `codex resume` reloads it and appends: reported straight away.
849            *lock.borrow_mut() = LockState::Held;
850            append(&path, "{}");
851            assert_eq!(
852                ids(&scan(
853                    tmp.path(),
854                    tmp.path(),
855                    &mut state,
856                    later,
857                    &scripted(&lock)
858                )),
859                vec![format!("seen:{ID}")]
860            );
861        }
862    }
863
864    #[test]
865    fn scanning_a_missing_root_is_empty() {
866        let lock = RefCell::new(LockState::Unknown);
867        let mut state = ScanState::new();
868        let missing = Path::new("/nonexistent/codex/sessions");
869        assert!(scan(
870            missing,
871            missing,
872            &mut state,
873            SystemTime::now(),
874            &scripted(&lock)
875        )
876        .is_empty());
877    }
878
879    #[cfg(unix)]
880    #[test]
881    fn probe_lock_tells_held_from_free_and_absent() {
882        use nix::fcntl::{Flock, FlockArg};
883
884        let tmp = tempfile::tempdir().unwrap();
885        // No lock directory at all: a Codex without locks, so no information.
886        assert_eq!(
887            probe_lock(&tmp.path().join("missing"), ID),
888            LockState::Unknown
889        );
890        assert_eq!(probe_lock(tmp.path(), ID), LockState::Absent);
891        let path = tmp.path().join(format!("{ID}.lock"));
892        std::fs::write(&path, "").unwrap();
893        assert_eq!(probe_lock(tmp.path(), ID), LockState::Free);
894        // Codex holds its lock exclusively, from another open file description.
895        let holder = Flock::lock(
896            std::fs::File::open(&path).unwrap(),
897            FlockArg::LockExclusiveNonblock,
898        )
899        .unwrap();
900        assert_eq!(probe_lock(tmp.path(), ID), LockState::Held);
901        // The probe did not disturb the holder: it still holds the lock.
902        assert_eq!(probe_lock(tmp.path(), ID), LockState::Held);
903        drop(holder);
904        assert_eq!(probe_lock(tmp.path(), ID), LockState::Free);
905    }
906
907    #[test]
908    fn actions_apply_to_the_registry_as_codex_sessions() {
909        let registry = SessionsRegistry::new();
910        Action::Seen {
911            id: ID.to_string(),
912            cwd: Some(PathBuf::from("/work/repo")),
913            transcript_path: PathBuf::from("/r.jsonl"),
914        }
915        .apply(&registry);
916        let listed = registry.list();
917        assert_eq!(listed.len(), 1);
918        assert_eq!(listed[0].agent, Agent::Codex);
919        assert_eq!(listed[0].state, SessionState::Idle);
920        assert_eq!(listed[0].cwd.as_deref(), Some(Path::new("/work/repo")));
921        Action::End { id: ID.to_string() }.apply(&registry);
922        assert_eq!(registry.list()[0].state, SessionState::Ended);
923    }
924}