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//! 4. **A hook-less session's turn state** (#2135). A session with no hook feed —
27//!    started before the hooks were installed and trusted — would otherwise read
28//!    `idle` for good. While its thread lock is held, the rollout's last turn
29//!    marker says whether a turn is running (below).
30//!
31//! What it reads, and what it never does:
32//!
33//! - The **first line** of a rollout (`session_meta`, bounded to
34//!   [`MAX_HEAD_BYTES`]), and from it only `id`/`session_id`, `cwd` and `source`.
35//! - A **bounded tail** (starting at [`TURN_TAIL_START`], widened to at most
36//!   [`TURN_TAIL_MAX`]), for the turn marker closest to the end. That is the
37//!   version-sensitive part of the file, so the read is schema-tolerant: a line
38//!   counts only if it parses as an `event_msg` whose `payload.type` is
39//!   `task_started`, `task_complete` or `turn_aborted`, and anything else — a
40//!   truncated or unknown tail, a line that merely quotes a marker name — is
41//!   ignored and changes nothing. Only an [`RolloutTurn`] leaves the read; no
42//!   conversation content is kept or logged.
43//! - Growth is otherwise size/mtime only. Nothing is logged.
44//! - **Subagent threads are skipped**: their `source` is an object
45//!   (`{"subagent": …}`), and their hook events already carry the parent's id.
46//! - Growth is a heartbeat, not `working`: a rollout keeps being written around
47//!   the turn's `Stop`, so it would hold an idle session at `working`. The state
48//!   comes from the **last turn marker** alone, never from counts or growth, and
49//!   only while the thread lock is *held* — a free lock means the session is gone
50//!   whatever the marker says, and a Codex without locks makes no claim.
51//! - A marker is reported on a **change**, not every scan. A level re-asserted
52//!   each scan would race a hook (`UserPromptSubmit` lands before Codex flushes
53//!   `task_started`, and the still-closed marker would read it back to `idle`).
54//!   The first read of a session claims `working` for an open turn, but never
55//!   `idle` for a closed one, so it cannot override what a hook reported. An open
56//!   marker in a rollout that has not been written to lately is not claimed, and
57//!   neither is the one a session was ended in: a crash leaves a turn open for
58//!   good, and `codex resume` of it holds the lock again. Markers are told apart
59//!   by the offset of their line, so only a *newer* `task_started` counts.
60//! - A read that fails is retried on the next scan, not remembered as done.
61//! - The report is passive ([`SessionEvent::RolloutTurn`]): a `waiting_for_*`
62//!   state a hook reported is held, an open turn does not start `working` over
63//!   an `idle` a hook or the wrapper reported, and the wrapper's `StreamState`,
64//!   re-asserted on every poll, has the last word. It can never report an
65//!   approval either — the rollout records none.
66//! - The lock probe is a non-blocking **shared** `flock` taken and dropped at
67//!   once: on a session's first sight, then once per scan while it is tracked
68//!   and not ended (an ended session is left alone until its rollout grows).
69//!   Against a live holder it fails harmlessly; it can only collide with Codex
70//!   acquiring the lock for that same thread in the same instant.
71//! - On first sight a rollout whose lock is free or gone is recorded but not
72//!   announced — a daemon restart must not list sessions that already ended.
73//! - A rollout that is *not* recent is no evidence the session is gone: a Codex
74//!   left idle at its prompt writes nothing, and one started before hooks were
75//!   installed has no hook to announce it either (#2108). So an unread rollout's
76//!   lock is probed too, by the thread id its file name ends in, on every scan;
77//!   a held lock announces it whatever its mtime. The name is only a prefilter
78//!   (a missing lock file is a cheap `ENOENT`): the head, read once the lock is
79//!   held, stays the authority for the id, `cwd` and the subagent check.
80
81use std::collections::{HashMap, HashSet};
82use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
83use std::path::{Path, PathBuf};
84use std::sync::Arc;
85use std::time::{Duration, SystemTime};
86
87use serde::Deserialize;
88use tokio::task::JoinHandle;
89use tokio_util::sync::CancellationToken;
90
91use super::{is_session_uuid, Agent, ObserveRequest, RolloutTurn, SessionEvent, SessionsRegistry};
92
93/// How often the lock of a rollout that has not been read is probed (#2108). A
94/// Codex home accumulates thousands of old rollouts, so probing each of them on
95/// every [`WATCH_INTERVAL`] would cost thousands of syscalls every few seconds for
96/// the daemon's whole life; a held lock on an old rollout is found within this
97/// instead (or at once, when the rollout is written to).
98const UNREAD_PROBE_INTERVAL: Duration = Duration::from_secs(60);
99
100/// Codex's home directory override, as Codex itself reads it.
101const CODEX_HOME_ENV: &str = "CODEX_HOME";
102
103/// How often the watcher rescans. Matches the Claude transcript watcher.
104const WATCH_INTERVAL: Duration = Duration::from_secs(5);
105
106/// A rollout must have been modified within this window to be announced when
107/// first seen, so a fresh daemon does not flood the registry with every
108/// historical session. Matches the registry's session TTL.
109const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(300);
110
111/// How often a live session (lock held, or rollout growing) is re-reported, to
112/// keep it inside the registry's 5-minute TTL without a report every scan.
113const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60);
114
115/// The most of a rollout's head line the watcher will read. `session_meta`
116/// embeds Codex's base instructions (~20 KiB today); a head without a newline
117/// within this bound is treated as unreadable.
118const MAX_HEAD_BYTES: u64 = 1024 * 1024;
119
120/// How much of a rollout's end is read first when looking for its last turn
121/// marker (#2135). A turn's markers sit among the conversation's own lines, so a
122/// busy turn pushes `task_started` far from the end; most rollouts hold theirs
123/// well inside this.
124const TURN_TAIL_START: u64 = 256 * 1024;
125
126/// The most of a rollout's end read to find a turn marker. A single line of tool
127/// output can run to several MiB, so a window inside one holds no marker at all;
128/// the window is widened [`TURN_TAIL_GROWTH`]-fold up to this before the marker
129/// is called unknown, which changes nothing.
130const TURN_TAIL_MAX: u64 = 4 * 1024 * 1024;
131
132/// How much wider the tail window gets each time it holds no marker.
133const TURN_TAIL_GROWTH: u64 = 4;
134
135/// The `event_msg` payload types that mark a turn's edges. A line is parsed only
136/// if it contains one of these, so nearly every line of a rollout is skipped
137/// without being decoded.
138const TURN_MARKER_NAMES: [&str; 3] = ["task_started", "task_complete", "turn_aborted"];
139
140/// Where Codex keeps its state: `$CODEX_HOME`, else `~/.codex`.
141fn codex_home() -> Option<PathBuf> {
142    if let Some(dir) = std::env::var_os(CODEX_HOME_ENV).filter(|d| !d.is_empty()) {
143        return Some(PathBuf::from(dir));
144    }
145    dirs::home_dir().map(|home| home.join(".codex"))
146}
147
148/// The fields of a rollout's head line the watcher reads. Everything else,
149/// including the embedded instructions, is skipped by serde.
150#[derive(Debug, Deserialize)]
151struct HeadLine {
152    #[serde(rename = "type")]
153    kind: String,
154    #[serde(default)]
155    payload: Option<SessionMeta>,
156}
157
158/// The `session_meta` payload fields the watcher reads.
159#[derive(Debug, Deserialize)]
160struct SessionMeta {
161    /// The thread id — the hook `session_id`, the rollout filename's id and the
162    /// lock filename, for a non-subagent thread.
163    #[serde(default)]
164    id: Option<String>,
165    #[serde(default)]
166    session_id: Option<String>,
167    #[serde(default)]
168    cwd: Option<PathBuf>,
169    /// `"cli"`, `"vscode"`, `"exec"`, … for a session; an object
170    /// (`{"subagent": …}`) for a subagent thread.
171    #[serde(default)]
172    source: Option<serde_json::Value>,
173}
174
175/// What a rollout's head says about it.
176#[derive(Debug, Clone, PartialEq, Eq)]
177enum Head {
178    /// A top-level session.
179    Session { id: String, cwd: Option<PathBuf> },
180    /// A subagent thread, or a head the watcher cannot read — never reported.
181    Skip,
182    /// The first line is not complete yet (Codex is still writing it); read
183    /// again once the file grows.
184    Incomplete,
185}
186
187/// Parses a rollout head line. Schema-tolerant: anything but a `session_meta`
188/// line with an id is [`Head::Skip`].
189fn parse_head(line: &str) -> Head {
190    let Ok(head) = serde_json::from_str::<HeadLine>(line) else {
191        return Head::Skip;
192    };
193    let Some(meta) = head.payload.filter(|_| head.kind == "session_meta") else {
194        return Head::Skip;
195    };
196    if meta
197        .source
198        .as_ref()
199        .is_some_and(serde_json::Value::is_object)
200    {
201        return Head::Skip;
202    }
203    match meta
204        .id
205        .or(meta.session_id)
206        .filter(|id| !id.trim().is_empty())
207    {
208        Some(id) => Head::Session { id, cwd: meta.cwd },
209        None => Head::Skip,
210    }
211}
212
213/// Reads and parses the first line of `path`, bounded to [`MAX_HEAD_BYTES`].
214fn read_head(path: &Path) -> Head {
215    let Ok(file) = std::fs::File::open(path) else {
216        return Head::Skip;
217    };
218    let mut line = String::new();
219    let mut reader = BufReader::new(file.take(MAX_HEAD_BYTES));
220    match reader.read_line(&mut line) {
221        Ok(_) if line.ends_with('\n') => parse_head(&line),
222        // Hit the bound without a newline: not a head this watcher will read.
223        Ok(n) if n as u64 >= MAX_HEAD_BYTES => Head::Skip,
224        Ok(_) => Head::Incomplete,
225        Err(_) => Head::Skip,
226    }
227}
228
229/// The fields of a rollout line the turn reader looks at. Everything else,
230/// including the conversation itself, is skipped by serde.
231#[derive(Debug, Deserialize)]
232struct EventLine {
233    #[serde(rename = "type")]
234    kind: String,
235    #[serde(default)]
236    payload: Option<EventPayload>,
237}
238
239/// The one `event_msg` payload field that names the event.
240#[derive(Debug, Deserialize)]
241struct EventPayload {
242    #[serde(rename = "type", default)]
243    kind: Option<String>,
244}
245
246/// What one rollout line says about a turn: `Some` only for an `event_msg`
247/// whose `payload.type` is a turn marker. A line that merely mentions a marker
248/// name (a `response_item` quoting one, say), or that does not parse, is `None`.
249fn parse_turn_marker(line: &str) -> Option<RolloutTurn> {
250    if !TURN_MARKER_NAMES.iter().any(|name| line.contains(name)) {
251        return None;
252    }
253    let event = serde_json::from_str::<EventLine>(line).ok()?;
254    if event.kind != "event_msg" {
255        return None;
256    }
257    match event.payload?.kind?.as_str() {
258        "task_started" => Some(RolloutTurn::Open),
259        "task_complete" | "turn_aborted" => Some(RolloutTurn::Closed),
260        _ => None,
261    }
262}
263
264/// A turn marker found in a rollout, and where its line begins.
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266struct Marker {
267    turn: RolloutTurn,
268    /// The byte offset of the marker's line from the start of the file. A rollout
269    /// is only ever appended to, so this names one marker for good: it tells a
270    /// marker already seen from a newer one that says the same thing.
271    at: u64,
272}
273
274/// The last turn marker in `window`, a stretch of a rollout ending at its end,
275/// with the offset of its line within `window`. `starts_mid_line` is whether the
276/// stretch begins past the start of the file, in which case its first line is a
277/// fragment and is dropped. A final line Codex has not finished writing fails to
278/// parse and is passed over, so the marker before it stands until the next scan.
279fn last_turn_marker(window: &[u8], starts_mid_line: bool) -> Option<(RolloutTurn, usize)> {
280    let first = if starts_mid_line {
281        window.iter().position(|byte| *byte == b'\n')? + 1
282    } else {
283        0
284    };
285    let mut end = window.len();
286    loop {
287        let begin = window
288            .get(first..end)?
289            .iter()
290            .rposition(|byte| *byte == b'\n')
291            .map_or(first, |newline| first + newline + 1);
292        let line = window.get(begin..end)?;
293        if let Some(turn) = std::str::from_utf8(line).ok().and_then(parse_turn_marker) {
294            return Some((turn, begin));
295        }
296        if begin == first {
297            return None;
298        }
299        end = begin - 1;
300    }
301}
302
303/// Reads the turn marker closest to the end of the rollout at `path`: the last
304/// [`TURN_TAIL_START`] bytes, widened [`TURN_TAIL_GROWTH`]-fold up to
305/// [`TURN_TAIL_MAX`] while they hold none. `Ok(None)` when no marker lies within
306/// that reach, which callers treat as "no information"; an `Err` is a read that
307/// failed, which they retry.
308fn read_last_turn(path: &Path) -> std::io::Result<Option<Marker>> {
309    let mut file = std::fs::File::open(path)?;
310    let len = file.metadata()?.len();
311    let mut window = TURN_TAIL_START;
312    loop {
313        let start = len.saturating_sub(window);
314        file.seek(SeekFrom::Start(start))?;
315        let mut bytes = Vec::new();
316        (&mut file).take(len - start).read_to_end(&mut bytes)?;
317        if let Some((turn, begin)) = last_turn_marker(&bytes, start > 0) {
318            return Ok(Some(Marker {
319                turn,
320                at: start + begin as u64,
321            }));
322        }
323        if start == 0 || window >= TURN_TAIL_MAX {
324            return Ok(None);
325        }
326        window = window.saturating_mul(TURN_TAIL_GROWTH).min(TURN_TAIL_MAX);
327    }
328}
329
330/// The state of a thread's writer lock.
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub(crate) enum LockState {
333    /// Codex holds it: the thread is loaded in a live process.
334    Held,
335    /// The file exists and nobody holds it: the holder exited or was killed.
336    Free,
337    /// No lock file in an existing lock directory: the thread is not loaded.
338    Absent,
339    /// The probe failed for another reason; treated as no information.
340    Unknown,
341}
342
343/// Probes `<locks>/<id>.lock` with a non-blocking shared `flock`, released at
344/// once. See the module docs for why this cannot disturb a live holder.
345#[cfg(unix)]
346fn probe_lock(locks: &Path, id: &str) -> LockState {
347    use nix::errno::Errno;
348    use nix::fcntl::{Flock, FlockArg};
349
350    let file = match std::fs::File::open(locks.join(format!("{id}.lock"))) {
351        Ok(file) => file,
352        // Without the directory this Codex keeps no locks at all, which says
353        // nothing about the session.
354        Err(e) if e.kind() == std::io::ErrorKind::NotFound && locks.is_dir() => {
355            return LockState::Absent
356        }
357        Err(_) => return LockState::Unknown,
358    };
359    match Flock::lock(file, FlockArg::LockSharedNonblock) {
360        // Dropping the guard releases the shared lock straight away.
361        Ok(_guard) => LockState::Free,
362        Err((_, Errno::EWOULDBLOCK)) => LockState::Held,
363        Err(_) => LockState::Unknown,
364    }
365}
366
367#[cfg(not(unix))]
368fn probe_lock(_locks: &Path, _id: &str) -> LockState {
369    LockState::Unknown
370}
371
372/// The state of thread `id`'s writer lock under the resolved Codex home, for the
373/// journal replay (#2108): a held lock is proof a live process has the thread
374/// loaded, a free or absent one that it is gone. [`LockState::Unknown`] when no
375/// Codex home resolves.
376pub(crate) fn probe_thread_lock(id: &str) -> LockState {
377    probe_thread_lock_in(codex_home().as_deref(), id)
378}
379
380/// [`probe_thread_lock`] under an already-resolved Codex home.
381fn probe_thread_lock_in(home: Option<&Path>, id: &str) -> LockState {
382    match home {
383        Some(home) => probe_lock(&home.join("thread-writer-locks"), id),
384        None => LockState::Unknown,
385    }
386}
387
388/// The watcher's record of one rollout file.
389#[derive(Debug, Clone)]
390enum Tracked {
391    /// Seen but not read: it was not recently active when first seen. Its head
392    /// is read if it later grows, or when its thread lock is found held.
393    /// `probed` is when that lock was last looked at (#2108).
394    Unread { size: u64, probed: SystemTime },
395    /// A subagent or unreadable rollout; never reported.
396    Skipped,
397    /// A session rollout.
398    Session(SessionTrack),
399}
400
401/// Per-session bookkeeping for a [`Tracked::Session`].
402#[derive(Debug, Clone)]
403struct SessionTrack {
404    id: String,
405    cwd: Option<PathBuf>,
406    size: u64,
407    /// Whether this watcher has seen the thread's lock held. Only then does a
408    /// free or missing lock mean the process is gone — a Codex without locks,
409    /// or a thread that was never loaded, must not read as ended.
410    lock_seen: bool,
411    /// Whether this watcher ended the session (it stops reporting it until the
412    /// rollout grows again, e.g. `codex resume`).
413    ended: bool,
414    /// When the session was last reported.
415    last_report: SystemTime,
416    /// The turn marker last established from the rollout's tail while the lock
417    /// was held, if any (#2135). A change from it is what gets reported.
418    turn: Option<Marker>,
419    /// The rollout size the tail was last read at, so an idle session whose
420    /// rollout has not changed costs no read. Unset after a failed read, which is
421    /// retried on the next scan.
422    turn_read_at: Option<u64>,
423    /// The offset of an open marker that must not be claimed as a running turn:
424    /// one declined for being old, or the one a session was ended in.
425    stale_open: Option<u64>,
426}
427
428impl SessionTrack {
429    /// Prepares a session that was ended and written to again (`codex resume`) to
430    /// be established afresh. A turn it died in stays open in the rollout until
431    /// the next one starts, so that marker is remembered as stale rather than
432    /// claimed as a turn the new process is running.
433    fn forget_turn(&mut self) {
434        if let Some(Marker {
435            turn: RolloutTurn::Open,
436            at,
437        }) = self.turn
438        {
439            self.stale_open = Some(at);
440        }
441        self.turn = None;
442        self.turn_read_at = None;
443    }
444
445    /// Reads the rollout's last turn marker when `lock` is held and the rollout
446    /// has changed since it was last read, returning the turn to report, if any
447    /// (#2135).
448    ///
449    /// The first marker established is handled with care, because a hook may
450    /// already have said more than the rollout can:
451    ///
452    /// - a closed turn is recorded but not reported, so it can never override a
453    ///   state a hook reported (a new session still defaults to `idle`);
454    /// - an open turn is reported only if the rollout was written to recently,
455    ///   and never if it is the marker already declined or the one the session
456    ///   ended in. A crash leaves a turn open for good, and `codex resume` of that
457    ///   holds the lock again, so an old open marker is no evidence a turn is
458    ///   running; only a newer `task_started` is.
459    ///
460    /// After that only a change is reported.
461    fn refresh_turn(
462        &mut self,
463        rollout: Rollout<'_>,
464        lock: LockState,
465        now: SystemTime,
466    ) -> Option<RolloutTurn> {
467        if lock != LockState::Held || self.turn_read_at == Some(rollout.size) {
468            return None;
469        }
470        // A failed read is retried on the next scan; remembering the size would
471        // leave a turn that just ended unread until the rollout grew again.
472        let found = read_last_turn(rollout.path).ok()?;
473        self.turn_read_at = Some(rollout.size);
474        let marker = found?;
475        match (self.turn, marker.turn) {
476            (None, RolloutTurn::Closed) => {
477                self.turn = Some(marker);
478                self.stale_open = None;
479                None
480            }
481            (None, RolloutTurn::Open) if self.stale_open == Some(marker.at) => None,
482            (None, RolloutTurn::Open) if !is_recent(rollout.modified, now) => {
483                self.stale_open = Some(marker.at);
484                None
485            }
486            // The same state again, possibly a newer marker for it: keep the
487            // latest offset, report nothing.
488            (Some(known), turn) if known.turn == turn => {
489                self.turn = Some(marker);
490                None
491            }
492            _ => {
493                self.turn = Some(marker);
494                self.stale_open = None;
495                Some(marker.turn)
496            }
497        }
498    }
499}
500
501/// A rollout file as a scan found it.
502#[derive(Debug, Clone, Copy)]
503struct Rollout<'a> {
504    path: &'a Path,
505    size: u64,
506    modified: SystemTime,
507}
508
509/// The watcher's state across scans, keyed by rollout path.
510type ScanState = HashMap<PathBuf, Tracked>;
511
512/// One thing a scan asks the registry to do.
513#[derive(Debug, Clone, PartialEq, Eq)]
514enum Action {
515    /// Report the session as present: keeping its current state, unless `turn`
516    /// carries a change in the rollout's last turn marker (#2135).
517    Seen {
518        id: String,
519        cwd: Option<PathBuf>,
520        transcript_path: PathBuf,
521        turn: Option<RolloutTurn>,
522    },
523    /// End the session.
524    End { id: String },
525}
526
527impl Action {
528    fn apply(self, registry: &SessionsRegistry) {
529        match self {
530            Self::Seen {
531                id,
532                cwd,
533                transcript_path,
534                turn,
535            } => registry.observe(ObserveRequest {
536                agent_id: None,
537                pid: None,
538                agent: Agent::Codex,
539                session_id: id,
540                cwd,
541                transcript_path: Some(transcript_path),
542                // Keeps the current state (a new session starts idle) unless a
543                // turn marker changed, and even that is passive evidence, so
544                // the watcher never overrides what the hooks reported.
545                event: turn.map_or(
546                    SessionEvent::TranscriptDiscovered,
547                    SessionEvent::RolloutTurn,
548                ),
549                repo: None,
550                model: None,
551            }),
552            Self::End { id } => {
553                registry.end(&id, Some("codex rollout watcher"), None);
554            }
555        }
556    }
557}
558
559/// Whether `modified` is within [`RECENT_ACTIVITY_WINDOW`] of `now` (a future
560/// mtime counts as recent).
561fn is_recent(modified: SystemTime, now: SystemTime) -> bool {
562    now.duration_since(modified)
563        .map_or(true, |elapsed| elapsed <= RECENT_ACTIVITY_WINDOW)
564}
565
566/// Every `rollout-*.jsonl` under `root/YYYY/MM/DD/`, with its size and mtime.
567fn list_rollouts(root: &Path) -> Vec<(PathBuf, u64, SystemTime)> {
568    let mut out = Vec::new();
569    let subdirs = |dir: &Path| -> Vec<PathBuf> {
570        std::fs::read_dir(dir)
571            .map(|entries| {
572                entries
573                    .flatten()
574                    .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
575                    .map(|e| e.path())
576                    .collect()
577            })
578            .unwrap_or_default()
579    };
580    for year in subdirs(root) {
581        for month in subdirs(&year) {
582            for day in subdirs(&month) {
583                let Ok(files) = std::fs::read_dir(&day) else {
584                    continue;
585                };
586                for file in files.flatten() {
587                    let path = file.path();
588                    let is_rollout = path.extension().is_some_and(|e| e == "jsonl")
589                        && path
590                            .file_name()
591                            .and_then(|n| n.to_str())
592                            .is_some_and(|n| n.starts_with("rollout-"));
593                    if !is_rollout {
594                        continue;
595                    }
596                    let Ok(meta) = file.metadata() else {
597                        continue;
598                    };
599                    let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
600                    out.push((path, meta.len(), modified));
601                }
602            }
603        }
604    }
605    out
606}
607
608/// Scans `sessions` (and probes locks under `locks`), updating `state` and
609/// returning what the registry should hear. `probe` is injected so tests can
610/// script lock states. Pure apart from `state` and the file reads.
611fn scan(
612    sessions: &Path,
613    locks: &Path,
614    state: &mut ScanState,
615    now: SystemTime,
616    probe: &dyn Fn(&Path, &str) -> LockState,
617) -> Vec<Action> {
618    let mut actions = Vec::new();
619    let mut present = HashSet::new();
620    for (path, size, modified) in list_rollouts(sessions) {
621        present.insert(path.clone());
622        let recent = is_recent(modified, now);
623        let rollout = Rollout {
624            path: &path,
625            size,
626            modified,
627        };
628        let tracked = match state.remove(&path) {
629            None if !recent => unread_or_held(rollout, None, locks, now, probe, &mut actions),
630            // First sight of a recent rollout, or growth of one not read yet.
631            None => first_read(rollout, locks, now, probe, &mut actions),
632            Some(Tracked::Unread { size: old, .. }) if size != old && recent => {
633                first_read(rollout, locks, now, probe, &mut actions)
634            }
635            Some(Tracked::Unread { probed, .. }) => {
636                unread_or_held(rollout, Some(probed), locks, now, probe, &mut actions)
637            }
638            Some(Tracked::Skipped) => Tracked::Skipped,
639            Some(Tracked::Session(track)) => {
640                Tracked::Session(rescan(track, rollout, locks, now, probe, &mut actions))
641            }
642        };
643        state.insert(path, tracked);
644    }
645    // A rollout that left the tree was archived (moved to `archived_sessions/`)
646    // or deleted: either way the session is over.
647    state.retain(|path, tracked| {
648        if present.contains(path) {
649            return true;
650        }
651        if let Tracked::Session(track) = tracked {
652            if !track.ended {
653                actions.push(Action::End {
654                    id: track.id.clone(),
655                });
656            }
657        }
658        false
659    });
660    actions
661}
662
663/// The thread id a rollout's file name ends in: `rollout-<timestamp>-<id>.jsonl`,
664/// where the id is a UUID. `None` for a name that does not end in one.
665fn thread_id_from_file_name(path: &Path) -> Option<&str> {
666    let stem = path.file_stem()?.to_str()?;
667    let split = stem.len().checked_sub(36)?;
668    let id = stem.get(split..)?;
669    let separated = split
670        .checked_sub(1)
671        .is_some_and(|dash| stem.as_bytes().get(dash) == Some(&b'-'));
672    (separated && is_session_uuid(id)).then_some(id)
673}
674
675/// Handles a rollout that is not recent and has not been read: it stays
676/// [`Tracked::Unread`] unless its thread's lock is held, in which case a live
677/// process has it loaded and it is read now, whatever its mtime (#2108). `probed`
678/// is when the lock was last looked at, if it has been: it is not looked at again
679/// before [`UNREAD_PROBE_INTERVAL`] has passed.
680fn unread_or_held(
681    rollout: Rollout<'_>,
682    probed: Option<SystemTime>,
683    locks: &Path,
684    now: SystemTime,
685    probe: &dyn Fn(&Path, &str) -> LockState,
686    actions: &mut Vec<Action>,
687) -> Tracked {
688    let size = rollout.size;
689    if let Some(last) = probed {
690        if now.duration_since(last).unwrap_or_default() < UNREAD_PROBE_INTERVAL {
691            return Tracked::Unread { size, probed: last };
692        }
693    }
694    match thread_id_from_file_name(rollout.path) {
695        Some(id) if probe(locks, id) == LockState::Held => {
696            first_read(rollout, locks, now, probe, actions)
697        }
698        _ => Tracked::Unread { size, probed: now },
699    }
700}
701
702/// Reads a rollout's head for the first time, announcing it when it is a
703/// session whose thread is not known to be gone. A thread whose lock is held also
704/// has its last turn marker read (#2135).
705fn first_read(
706    rollout: Rollout<'_>,
707    locks: &Path,
708    now: SystemTime,
709    probe: &dyn Fn(&Path, &str) -> LockState,
710    actions: &mut Vec<Action>,
711) -> Tracked {
712    let (id, cwd) = match read_head(rollout.path) {
713        Head::Skip => return Tracked::Skipped,
714        Head::Incomplete => {
715            return Tracked::Unread {
716                size: rollout.size,
717                probed: now,
718            }
719        }
720        Head::Session { id, cwd } => (id, cwd),
721    };
722    let lock = probe(locks, &id);
723    // A free or missing lock means the thread already ended (the daemon started
724    // after it did): record it, so later growth revives it, but do not list it.
725    let gone = matches!(lock, LockState::Free | LockState::Absent);
726    let mut track = SessionTrack {
727        id,
728        cwd,
729        size: rollout.size,
730        lock_seen: lock == LockState::Held,
731        ended: gone,
732        last_report: now,
733        turn: None,
734        turn_read_at: None,
735        stale_open: None,
736    };
737    if !gone {
738        let turn = track.refresh_turn(rollout, lock, now);
739        actions.push(Action::Seen {
740            id: track.id.clone(),
741            cwd: track.cwd.clone(),
742            transcript_path: rollout.path.to_path_buf(),
743            turn,
744        });
745    }
746    Tracked::Session(track)
747}
748
749/// Re-examines a known session: growth revives an ended one, a lock seen held
750/// and now released ends it, a change in the rollout's last turn marker is
751/// reported at once, and a live one is heartbeated.
752fn rescan(
753    mut track: SessionTrack,
754    rollout: Rollout<'_>,
755    locks: &Path,
756    now: SystemTime,
757    probe: &dyn Fn(&Path, &str) -> LockState,
758    actions: &mut Vec<Action>,
759) -> SessionTrack {
760    let grew = rollout.size > track.size;
761    track.size = rollout.size;
762    if track.ended {
763        if !grew {
764            return track;
765        }
766        // Written to again after we ended it (`codex resume`): start over, and
767        // report it this scan rather than a heartbeat interval from now.
768        track.ended = false;
769        track.lock_seen = false;
770        track.last_report = SystemTime::UNIX_EPOCH;
771        track.forget_turn();
772    }
773    let lock = probe(locks, &track.id);
774    if lock == LockState::Held {
775        track.lock_seen = true;
776    }
777    if track.lock_seen && matches!(lock, LockState::Free | LockState::Absent) {
778        track.ended = true;
779        actions.push(Action::End {
780            id: track.id.clone(),
781        });
782        return track;
783    }
784    let turn = track.refresh_turn(rollout, lock, now);
785    let alive = grew || lock == LockState::Held;
786    let due = now
787        .duration_since(track.last_report)
788        .is_ok_and(|since| since >= HEARTBEAT_INTERVAL);
789    // A turn change is reported when it is read, not a heartbeat interval later,
790    // and it counts as the heartbeat.
791    if turn.is_some() || (alive && due) {
792        track.last_report = now;
793        actions.push(Action::Seen {
794            id: track.id.clone(),
795            cwd: track.cwd.clone(),
796            transcript_path: rollout.path.to_path_buf(),
797            turn,
798        });
799    }
800    track
801}
802
803/// Spawns the watcher loop, returning its [`JoinHandle`].
804///
805/// Rescans every [`WATCH_INTERVAL`] and applies each action to `registry`,
806/// until `token` is cancelled. Parks idle when no Codex home can be resolved.
807/// Must be called from within a tokio runtime.
808pub fn spawn(registry: Arc<SessionsRegistry>, token: CancellationToken) -> JoinHandle<()> {
809    tokio::spawn(async move {
810        let Some(home) = codex_home() else {
811            tracing::debug!("no Codex home; Codex rollout watcher idle");
812            token.cancelled().await;
813            return;
814        };
815        let sessions = home.join("sessions");
816        let locks = home.join("thread-writer-locks");
817        tracing::debug!("Codex rollout watcher scanning {}", sessions.display());
818        let mut state = ScanState::new();
819        loop {
820            let (scan_sessions, scan_locks) = (sessions.clone(), locks.clone());
821            // Directory walks, head reads and lock probes are blocking I/O.
822            let mut owned = std::mem::take(&mut state);
823            let (returned, actions) = tokio::task::spawn_blocking(move || {
824                let actions = scan(
825                    &scan_sessions,
826                    &scan_locks,
827                    &mut owned,
828                    SystemTime::now(),
829                    &probe_lock,
830                );
831                (owned, actions)
832            })
833            .await
834            .unwrap_or_else(|_| (ScanState::new(), Vec::new()));
835            state = returned;
836            for action in actions {
837                action.apply(&registry);
838            }
839            tokio::select! {
840                () = token.cancelled() => break,
841                () = tokio::time::sleep(WATCH_INTERVAL) => {}
842            }
843        }
844    })
845}
846
847#[cfg(test)]
848#[allow(clippy::unwrap_used, clippy::expect_used)]
849mod tests {
850    use super::*;
851    use std::cell::RefCell;
852    use std::io::Write;
853
854    use crate::sessions::SessionState;
855
856    const ID: &str = "019a0000-0000-7000-8000-00000000c0de";
857
858    fn head(id: &str, source: serde_json::Value) -> String {
859        serde_json::json!({
860            "timestamp": "2026-09-25T00:00:00Z",
861            "type": "session_meta",
862            "payload": {
863                "id": id,
864                "session_id": id,
865                "cwd": "/work/repo",
866                "originator": "codex-tui",
867                "source": source,
868                "base_instructions": { "text": "x".repeat(2048) },
869            },
870        })
871        .to_string()
872    }
873
874    /// Writes `root/2026/09/25/rollout-…-<id>.jsonl` with `lines`.
875    fn write_rollout(root: &Path, id: &str, lines: &[String]) -> PathBuf {
876        let dir = root.join("2026").join("09").join("25");
877        std::fs::create_dir_all(&dir).unwrap();
878        let path = dir.join(format!("rollout-2026-09-25T00-00-00-{id}.jsonl"));
879        let mut f = std::fs::File::create(&path).unwrap();
880        for line in lines {
881            writeln!(f, "{line}").unwrap();
882        }
883        path
884    }
885
886    fn append(path: &Path, line: &str) {
887        let mut f = std::fs::OpenOptions::new().append(true).open(path).unwrap();
888        writeln!(f, "{line}").unwrap();
889    }
890
891    /// A scripted lock probe: returns whatever the cell currently holds.
892    fn scripted(cell: &RefCell<LockState>) -> impl Fn(&Path, &str) -> LockState + '_ {
893        move |_, _| *cell.borrow()
894    }
895
896    fn ids(actions: &[Action]) -> Vec<String> {
897        actions
898            .iter()
899            .map(|a| match a {
900                Action::Seen { id, .. } => format!("seen:{id}"),
901                Action::End { id } => format!("end:{id}"),
902            })
903            .collect()
904    }
905
906    #[test]
907    fn parse_head_reads_a_session_and_skips_subagents_and_junk() {
908        assert_eq!(
909            parse_head(&head(ID, serde_json::json!("cli"))),
910            Head::Session {
911                id: ID.to_string(),
912                cwd: Some(PathBuf::from("/work/repo")),
913            }
914        );
915        let subagent = serde_json::json!({ "subagent": { "thread_spawn": {} } });
916        assert_eq!(parse_head(&head(ID, subagent)), Head::Skip);
917        assert_eq!(parse_head("not json"), Head::Skip);
918        assert_eq!(
919            parse_head(r#"{"type":"response_item","payload":{"id":"x"}}"#),
920            Head::Skip
921        );
922        assert_eq!(
923            parse_head(r#"{"type":"session_meta","payload":{"cwd":"/w"}}"#),
924            Head::Skip
925        );
926        // `session_id` stands in when `id` is absent.
927        assert_eq!(
928            parse_head(r#"{"type":"session_meta","payload":{"session_id":"s"}}"#),
929            Head::Session {
930                id: "s".to_string(),
931                cwd: None
932            }
933        );
934    }
935
936    #[test]
937    fn read_head_needs_a_complete_first_line() {
938        let tmp = tempfile::tempdir().unwrap();
939        let path = tmp.path().join("r.jsonl");
940        std::fs::write(&path, head(ID, serde_json::json!("vscode"))).unwrap();
941        // No trailing newline yet: Codex is mid-write, so it is read later.
942        assert_eq!(read_head(&path), Head::Incomplete);
943        std::fs::write(&path, "").unwrap();
944        assert_eq!(read_head(&path), Head::Incomplete);
945        std::fs::write(
946            &path,
947            format!("{}\n{{}}\n", head(ID, serde_json::json!("vscode"))),
948        )
949        .unwrap();
950        assert!(matches!(read_head(&path), Head::Session { .. }));
951        assert_eq!(read_head(&tmp.path().join("missing")), Head::Skip);
952    }
953
954    #[test]
955    fn a_recent_rollout_is_discovered_once_with_its_cwd() {
956        let tmp = tempfile::tempdir().unwrap();
957        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
958        let lock = RefCell::new(LockState::Unknown);
959        let mut state = ScanState::new();
960        let now = SystemTime::now();
961        let first = scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock));
962        assert_eq!(
963            first,
964            vec![Action::Seen {
965                id: ID.to_string(),
966                cwd: Some(PathBuf::from("/work/repo")),
967                transcript_path: path,
968                turn: None,
969            }]
970        );
971        // Nothing new, nothing due: silence.
972        assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
973    }
974
975    #[test]
976    fn subagent_rollouts_and_non_rollout_files_are_never_reported() {
977        let tmp = tempfile::tempdir().unwrap();
978        let sub = serde_json::json!({ "subagent": "review" });
979        write_rollout(tmp.path(), "child", &[head("child", sub)]);
980        let dir = tmp.path().join("2026/09/25");
981        std::fs::write(dir.join("notes.jsonl"), "{}\n").unwrap();
982        std::fs::write(tmp.path().join("rollout-loose.jsonl"), "{}\n").unwrap();
983        let lock = RefCell::new(LockState::Held);
984        let mut state = ScanState::new();
985        let now = SystemTime::now();
986        assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
987        let later = now + HEARTBEAT_INTERVAL * 2;
988        assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
989    }
990
991    #[test]
992    fn an_old_rollout_is_recorded_silently_and_read_when_it_grows() {
993        let tmp = tempfile::tempdir().unwrap();
994        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
995        let lock = RefCell::new(LockState::Unknown);
996        let mut state = ScanState::new();
997        // Seen from far in the future, the file is not recent.
998        let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
999        assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
1000        assert!(matches!(state[&path], Tracked::Unread { .. }));
1001        // Resumed: it grows and is recent again, so its head is read now.
1002        append(&path, "{}");
1003        let now = SystemTime::now();
1004        assert_eq!(
1005            ids(&scan(
1006                tmp.path(),
1007                tmp.path(),
1008                &mut state,
1009                now,
1010                &scripted(&lock)
1011            )),
1012            vec![format!("seen:{ID}")]
1013        );
1014    }
1015
1016    #[test]
1017    fn an_old_rollout_whose_lock_is_held_is_announced_regardless_of_its_age() {
1018        // #2108: a Codex started before hooks were installed, idle for hours,
1019        // still holds its thread lock while it lives.
1020        let tmp = tempfile::tempdir().unwrap();
1021        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
1022        let lock = RefCell::new(LockState::Held);
1023        let mut state = ScanState::new();
1024        let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
1025        assert_eq!(
1026            scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)),
1027            vec![Action::Seen {
1028                id: ID.to_string(),
1029                cwd: Some(PathBuf::from("/work/repo")),
1030                transcript_path: path.clone(),
1031                turn: None,
1032            }]
1033        );
1034        assert!(matches!(state[&path], Tracked::Session(_)));
1035        // Tracked now: held and not yet due is quiet, and the release ends it.
1036        assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
1037        *lock.borrow_mut() = LockState::Free;
1038        assert_eq!(
1039            ids(&scan(
1040                tmp.path(),
1041                tmp.path(),
1042                &mut state,
1043                far,
1044                &scripted(&lock)
1045            )),
1046            vec![format!("end:{ID}")]
1047        );
1048    }
1049
1050    #[test]
1051    fn an_old_rollout_is_announced_when_its_lock_becomes_held_later() {
1052        // A `codex resume` that has not written yet: still `Unread`, then the
1053        // lock appears and a later probe announces it without any growth.
1054        let tmp = tempfile::tempdir().unwrap();
1055        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
1056        let lock = RefCell::new(LockState::Absent);
1057        let mut state = ScanState::new();
1058        let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
1059        assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
1060        assert!(matches!(state[&path], Tracked::Unread { .. }));
1061        *lock.borrow_mut() = LockState::Held;
1062        assert_eq!(
1063            ids(&scan(
1064                tmp.path(),
1065                tmp.path(),
1066                &mut state,
1067                far + UNREAD_PROBE_INTERVAL,
1068                &scripted(&lock)
1069            )),
1070            vec![format!("seen:{ID}")]
1071        );
1072    }
1073
1074    #[test]
1075    fn unread_rollouts_are_probed_once_per_interval_not_every_scan() {
1076        // A Codex home holds thousands of old rollouts; probing each on every
1077        // 5s scan would be thousands of syscalls for the daemon's whole life.
1078        let tmp = tempfile::tempdir().unwrap();
1079        write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
1080        let probes = std::cell::Cell::new(0_u32);
1081        let counting = |_: &Path, _: &str| {
1082            probes.set(probes.get() + 1);
1083            LockState::Absent
1084        };
1085        let mut state = ScanState::new();
1086        let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
1087        scan(tmp.path(), tmp.path(), &mut state, far, &counting);
1088        assert_eq!(probes.get(), 1, "probed on first sight");
1089        for scans in 1..=10 {
1090            scan(
1091                tmp.path(),
1092                tmp.path(),
1093                &mut state,
1094                far + WATCH_INTERVAL * scans,
1095                &counting,
1096            );
1097        }
1098        assert_eq!(probes.get(), 1, "not again within the interval");
1099        scan(
1100            tmp.path(),
1101            tmp.path(),
1102            &mut state,
1103            far + UNREAD_PROBE_INTERVAL,
1104            &counting,
1105        );
1106        assert_eq!(probes.get(), 2, "once the interval has passed");
1107    }
1108
1109    #[test]
1110    fn an_old_rollout_with_a_free_or_unknown_lock_stays_unread() {
1111        let tmp = tempfile::tempdir().unwrap();
1112        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
1113        let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
1114        for held in [LockState::Free, LockState::Absent, LockState::Unknown] {
1115            let lock = RefCell::new(held);
1116            let mut state = ScanState::new();
1117            assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
1118            assert!(matches!(state[&path], Tracked::Unread { .. }), "{held:?}");
1119        }
1120    }
1121
1122    #[test]
1123    fn an_old_subagent_rollout_with_a_held_lock_is_skipped_not_announced() {
1124        let tmp = tempfile::tempdir().unwrap();
1125        let sub = serde_json::json!({ "subagent": "review" });
1126        let path = write_rollout(tmp.path(), ID, &[head(ID, sub)]);
1127        let lock = RefCell::new(LockState::Held);
1128        let mut state = ScanState::new();
1129        let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
1130        assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
1131        assert!(matches!(state[&path], Tracked::Skipped));
1132    }
1133
1134    #[test]
1135    fn thread_ids_come_only_from_a_uuid_ending_the_file_name() {
1136        let named = |name: &str| thread_id_from_file_name(Path::new(name)).map(str::to_string);
1137        assert_eq!(
1138            named(&format!("rollout-2026-09-25T00-00-00-{ID}.jsonl")),
1139            Some(ID.to_string())
1140        );
1141        // No UUID, a short name, a non-hex group, or no separating dash.
1142        assert_eq!(named("rollout-2026-09-25T00-00-00-child.jsonl"), None);
1143        assert_eq!(named("x.jsonl"), None);
1144        assert_eq!(
1145            named("rollout-019a0000-0000-7000-8000-00000000zzzz.jsonl"),
1146            None
1147        );
1148        assert_eq!(named(&format!("rollout{ID}.jsonl")), None);
1149        assert_eq!(named(&format!("{ID}.jsonl")), None);
1150    }
1151
1152    #[test]
1153    fn a_held_lock_heartbeats_and_its_release_ends_the_session() {
1154        let tmp = tempfile::tempdir().unwrap();
1155        write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
1156        let lock = RefCell::new(LockState::Held);
1157        let mut state = ScanState::new();
1158        let t0 = SystemTime::now();
1159        assert_eq!(
1160            scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock)).len(),
1161            1
1162        );
1163        // Held, not yet due: quiet. Held and due: a heartbeat.
1164        let t1 = t0 + WATCH_INTERVAL;
1165        assert!(scan(tmp.path(), tmp.path(), &mut state, t1, &scripted(&lock)).is_empty());
1166        let t2 = t0 + HEARTBEAT_INTERVAL;
1167        assert_eq!(
1168            ids(&scan(
1169                tmp.path(),
1170                tmp.path(),
1171                &mut state,
1172                t2,
1173                &scripted(&lock)
1174            )),
1175            vec![format!("seen:{ID}")]
1176        );
1177        // The process was killed: the kernel dropped its lock.
1178        *lock.borrow_mut() = LockState::Free;
1179        assert_eq!(
1180            ids(&scan(
1181                tmp.path(),
1182                tmp.path(),
1183                &mut state,
1184                t2,
1185                &scripted(&lock)
1186            )),
1187            vec![format!("end:{ID}")]
1188        );
1189        // Ended once, not again.
1190        let t3 = t2 + HEARTBEAT_INTERVAL;
1191        assert!(scan(tmp.path(), tmp.path(), &mut state, t3, &scripted(&lock)).is_empty());
1192    }
1193
1194    #[test]
1195    fn a_lock_never_seen_held_does_not_end_the_session() {
1196        // An older Codex without a lock directory probes `Unknown`.
1197        let tmp = tempfile::tempdir().unwrap();
1198        write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
1199        let lock = RefCell::new(LockState::Unknown);
1200        let mut state = ScanState::new();
1201        let t0 = SystemTime::now();
1202        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
1203        let later = t0 + HEARTBEAT_INTERVAL * 3;
1204        assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
1205        // An unknown probe after a held one is not an end either.
1206        *lock.borrow_mut() = LockState::Held;
1207        scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock));
1208        *lock.borrow_mut() = LockState::Unknown;
1209        let actions = scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock));
1210        assert!(!ids(&actions).iter().any(|a| a.starts_with("end:")));
1211    }
1212
1213    #[test]
1214    fn growth_without_a_lock_is_a_heartbeat_not_a_state() {
1215        let tmp = tempfile::tempdir().unwrap();
1216        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("exec"))]);
1217        let lock = RefCell::new(LockState::Unknown);
1218        let mut state = ScanState::new();
1219        let t0 = SystemTime::now();
1220        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
1221        append(&path, "{}");
1222        let due = t0 + HEARTBEAT_INTERVAL;
1223        assert_eq!(
1224            ids(&scan(
1225                tmp.path(),
1226                tmp.path(),
1227                &mut state,
1228                due,
1229                &scripted(&lock)
1230            )),
1231            vec![format!("seen:{ID}")]
1232        );
1233    }
1234
1235    #[test]
1236    fn an_archived_rollout_ends_its_session_once() {
1237        let tmp = tempfile::tempdir().unwrap();
1238        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("vscode"))]);
1239        let lock = RefCell::new(LockState::Held);
1240        let mut state = ScanState::new();
1241        let now = SystemTime::now();
1242        scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock));
1243        // Archiving moves the file out of the watched tree.
1244        let archived = tmp.path().join("archived.jsonl");
1245        std::fs::rename(&path, archived).unwrap();
1246        assert_eq!(
1247            ids(&scan(
1248                tmp.path(),
1249                tmp.path(),
1250                &mut state,
1251                now,
1252                &scripted(&lock)
1253            )),
1254            vec![format!("end:{ID}")]
1255        );
1256        assert!(state.is_empty());
1257        assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
1258    }
1259
1260    #[test]
1261    fn a_resumed_session_is_reported_again_after_an_end() {
1262        let tmp = tempfile::tempdir().unwrap();
1263        let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
1264        let lock = RefCell::new(LockState::Held);
1265        let mut state = ScanState::new();
1266        let t0 = SystemTime::now();
1267        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
1268        *lock.borrow_mut() = LockState::Absent;
1269        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
1270        // `codex resume <id>` appends to the same rollout under the same id.
1271        *lock.borrow_mut() = LockState::Held;
1272        append(&path, "{}");
1273        let due = t0 + HEARTBEAT_INTERVAL;
1274        assert_eq!(
1275            ids(&scan(
1276                tmp.path(),
1277                tmp.path(),
1278                &mut state,
1279                due,
1280                &scripted(&lock)
1281            )),
1282            vec![format!("seen:{ID}")]
1283        );
1284    }
1285
1286    #[test]
1287    fn a_head_still_being_written_is_read_once_it_is_complete() {
1288        let tmp = tempfile::tempdir().unwrap();
1289        let dir = tmp.path().join("2026/09/25");
1290        std::fs::create_dir_all(&dir).unwrap();
1291        let path = dir.join(format!("rollout-2026-09-25T00-00-00-{ID}.jsonl"));
1292        let full = head(ID, serde_json::json!("cli"));
1293        std::fs::write(&path, &full[..full.len() / 2]).unwrap();
1294        let lock = RefCell::new(LockState::Held);
1295        let mut state = ScanState::new();
1296        let now = SystemTime::now();
1297        assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
1298        assert!(matches!(state[&path], Tracked::Unread { .. }));
1299        std::fs::write(&path, format!("{full}\n")).unwrap();
1300        assert_eq!(
1301            ids(&scan(
1302                tmp.path(),
1303                tmp.path(),
1304                &mut state,
1305                now,
1306                &scripted(&lock)
1307            )),
1308            vec![format!("seen:{ID}")]
1309        );
1310    }
1311
1312    #[test]
1313    fn a_restart_does_not_list_a_session_whose_lock_is_already_released() {
1314        for gone in [LockState::Free, LockState::Absent] {
1315            let tmp = tempfile::tempdir().unwrap();
1316            let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
1317            let lock = RefCell::new(gone);
1318            let mut state = ScanState::new();
1319            let now = SystemTime::now();
1320            assert!(
1321                scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty(),
1322                "{gone:?}"
1323            );
1324            // Not listed, not ended: nothing to say until it is written to again.
1325            let later = now + HEARTBEAT_INTERVAL * 2;
1326            assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
1327            // `codex resume` reloads it and appends: reported straight away.
1328            *lock.borrow_mut() = LockState::Held;
1329            append(&path, "{}");
1330            assert_eq!(
1331                ids(&scan(
1332                    tmp.path(),
1333                    tmp.path(),
1334                    &mut state,
1335                    later,
1336                    &scripted(&lock)
1337                )),
1338                vec![format!("seen:{ID}")]
1339            );
1340        }
1341    }
1342
1343    #[test]
1344    fn scanning_a_missing_root_is_empty() {
1345        let lock = RefCell::new(LockState::Unknown);
1346        let mut state = ScanState::new();
1347        let missing = Path::new("/nonexistent/codex/sessions");
1348        assert!(scan(
1349            missing,
1350            missing,
1351            &mut state,
1352            SystemTime::now(),
1353            &scripted(&lock)
1354        )
1355        .is_empty());
1356    }
1357
1358    #[cfg(unix)]
1359    #[test]
1360    fn probe_lock_tells_held_from_free_and_absent() {
1361        use nix::fcntl::{Flock, FlockArg};
1362
1363        let tmp = tempfile::tempdir().unwrap();
1364        // No lock directory at all: a Codex without locks, so no information.
1365        assert_eq!(
1366            probe_lock(&tmp.path().join("missing"), ID),
1367            LockState::Unknown
1368        );
1369        assert_eq!(probe_lock(tmp.path(), ID), LockState::Absent);
1370        let path = tmp.path().join(format!("{ID}.lock"));
1371        std::fs::write(&path, "").unwrap();
1372        assert_eq!(probe_lock(tmp.path(), ID), LockState::Free);
1373        // Codex holds its lock exclusively, from another open file description.
1374        let holder = Flock::lock(
1375            std::fs::File::open(&path).unwrap(),
1376            FlockArg::LockExclusiveNonblock,
1377        )
1378        .unwrap();
1379        assert_eq!(probe_lock(tmp.path(), ID), LockState::Held);
1380        // The probe did not disturb the holder: it still holds the lock.
1381        assert_eq!(probe_lock(tmp.path(), ID), LockState::Held);
1382        drop(holder);
1383        assert_eq!(probe_lock(tmp.path(), ID), LockState::Free);
1384    }
1385
1386    #[test]
1387    fn the_thread_lock_probe_reads_the_resolved_homes_lock_directory() {
1388        let tmp = tempfile::tempdir().unwrap();
1389        // No home resolves at all: no information.
1390        assert_eq!(probe_thread_lock_in(None, ID), LockState::Unknown);
1391        // A home without a lock directory is a Codex that keeps no locks.
1392        assert_eq!(
1393            probe_thread_lock_in(Some(tmp.path()), ID),
1394            LockState::Unknown
1395        );
1396        let locks = tmp.path().join("thread-writer-locks");
1397        std::fs::create_dir(&locks).unwrap();
1398        assert_eq!(
1399            probe_thread_lock_in(Some(tmp.path()), ID),
1400            LockState::Absent
1401        );
1402        std::fs::write(locks.join(format!("{ID}.lock")), "").unwrap();
1403        assert_eq!(probe_thread_lock_in(Some(tmp.path()), ID), LockState::Free);
1404    }
1405
1406    #[test]
1407    fn actions_apply_to_the_registry_as_codex_sessions() {
1408        let registry = SessionsRegistry::new();
1409        Action::Seen {
1410            id: ID.to_string(),
1411            cwd: Some(PathBuf::from("/work/repo")),
1412            transcript_path: PathBuf::from("/r.jsonl"),
1413            turn: None,
1414        }
1415        .apply(&registry);
1416        let listed = registry.list();
1417        assert_eq!(listed.len(), 1);
1418        assert_eq!(listed[0].agent, Agent::Codex);
1419        assert_eq!(listed[0].state, SessionState::Idle);
1420        assert_eq!(listed[0].cwd.as_deref(), Some(Path::new("/work/repo")));
1421        Action::End { id: ID.to_string() }.apply(&registry);
1422        assert_eq!(registry.list()[0].state, SessionState::Ended);
1423    }
1424
1425    // ── Turn markers (#2135) ─────────────────────────────────────────────────
1426
1427    /// An `event_msg` line with the given payload type, as Codex writes them.
1428    fn marker(kind: &str) -> String {
1429        serde_json::json!({
1430            "timestamp": "2026-09-25T00:00:01Z",
1431            "type": "event_msg",
1432            "payload": { "type": kind, "turn_id": "turn-1" },
1433        })
1434        .to_string()
1435    }
1436
1437    fn started() -> String {
1438        marker("task_started")
1439    }
1440
1441    fn complete() -> String {
1442        marker("task_complete")
1443    }
1444
1445    fn aborted() -> String {
1446        marker("turn_aborted")
1447    }
1448
1449    /// A conversation line of about `bytes` bytes that is not a turn marker.
1450    fn chatter(bytes: usize) -> String {
1451        serde_json::json!({
1452            "timestamp": "2026-09-25T00:00:02Z",
1453            "type": "response_item",
1454            "payload": { "type": "message", "role": "assistant", "content": "x".repeat(bytes) },
1455        })
1456        .to_string()
1457    }
1458
1459    /// A conversation line whose text names every marker.
1460    fn quoting() -> String {
1461        serde_json::json!({
1462            "type": "response_item",
1463            "payload": { "type": "message", "content": "task_started task_complete turn_aborted" },
1464        })
1465        .to_string()
1466    }
1467
1468    /// The turn `read_last_turn` finds in the rollout at `path`.
1469    fn turn_at(path: &Path) -> Option<RolloutTurn> {
1470        read_last_turn(path).unwrap().map(|marker| marker.turn)
1471    }
1472
1473    /// The marker `read_last_turn` finds in a rollout holding a head and `lines`.
1474    fn turn_of(lines: &[String]) -> Option<RolloutTurn> {
1475        let tmp = tempfile::tempdir().unwrap();
1476        let mut all = vec![head(ID, serde_json::json!("cli"))];
1477        all.extend_from_slice(lines);
1478        turn_at(&write_rollout(tmp.path(), ID, &all))
1479    }
1480
1481    fn turns(actions: &[Action]) -> Vec<Option<RolloutTurn>> {
1482        actions
1483            .iter()
1484            .filter_map(|a| match a {
1485                Action::Seen { turn, .. } => Some(*turn),
1486                Action::End { .. } => None,
1487            })
1488            .collect()
1489    }
1490
1491    fn track_of<'a>(state: &'a ScanState, path: &Path) -> &'a SessionTrack {
1492        match &state[path] {
1493            Tracked::Session(track) => track,
1494            other => panic!("not a session: {other:?}"),
1495        }
1496    }
1497
1498    /// Applies `actions` to `registry` and returns the one session's state.
1499    fn state_after(registry: &SessionsRegistry, actions: Vec<Action>) -> SessionState {
1500        for action in actions {
1501            action.apply(registry);
1502        }
1503        registry.list()[0].state
1504    }
1505
1506    #[test]
1507    fn only_an_event_msg_marker_counts_as_a_turn_marker() {
1508        assert_eq!(parse_turn_marker(&started()), Some(RolloutTurn::Open));
1509        assert_eq!(parse_turn_marker(&complete()), Some(RolloutTurn::Closed));
1510        assert_eq!(parse_turn_marker(&aborted()), Some(RolloutTurn::Closed));
1511        // A conversation line that quotes the names is not a marker, nor is an
1512        // `event_msg` of another kind that mentions one.
1513        assert_eq!(parse_turn_marker(&quoting()), None);
1514        let other = serde_json::json!({
1515            "type": "event_msg",
1516            "payload": { "type": "agent_message", "message": "task_started" },
1517        });
1518        assert_eq!(parse_turn_marker(&other.to_string()), None);
1519        // Nothing here is the schema the reader understands.
1520        for line in [
1521            "task_started",
1522            "",
1523            r#"{"type":"event_msg"}"#,
1524            r#"{"type":"event_msg","payload":"task_started"}"#,
1525            r#"{"type":"event_msg","payload":{"n":"task_started"}}"#,
1526            r#"{"type":"event_msg","payload":{"type":7,"n":"task_started"}}"#,
1527            r#"{"type":5,"payload":{"type":"task_started"}}"#,
1528        ] {
1529            assert_eq!(parse_turn_marker(line), None, "{line}");
1530        }
1531    }
1532
1533    #[test]
1534    fn the_last_marker_wins_whatever_the_counts() {
1535        let cases: Vec<(Vec<String>, Option<RolloutTurn>)> = vec![
1536            (vec![started()], Some(RolloutTurn::Open)),
1537            (vec![started(), complete()], Some(RolloutTurn::Closed)),
1538            (vec![started(), aborted()], Some(RolloutTurn::Closed)),
1539            (
1540                vec![started(), complete(), started()],
1541                Some(RolloutTurn::Open),
1542            ),
1543            // Seven corpus files carry one completion too many.
1544            (
1545                vec![started(), complete(), complete()],
1546                Some(RolloutTurn::Closed),
1547            ),
1548            // Conversation after the marker, quoting markers, changes nothing.
1549            (
1550                vec![started(), chatter(10), quoting(), chatter(10)],
1551                Some(RolloutTurn::Open),
1552            ),
1553            (vec![chatter(10), quoting()], None),
1554            (vec![], None),
1555        ];
1556        for (lines, expected) in cases {
1557            assert_eq!(turn_of(&lines), expected, "{lines:?}");
1558        }
1559    }
1560
1561    #[test]
1562    fn an_unreadable_rollout_is_an_error_and_a_markerless_one_is_not() {
1563        // A failed read is retried; a read that found no marker is final.
1564        assert!(read_last_turn(Path::new("/nonexistent/rollout.jsonl")).is_err());
1565        let tmp = tempfile::tempdir().unwrap();
1566        let path = tmp.path().join("r.jsonl");
1567        std::fs::write(&path, b"").unwrap();
1568        assert_eq!(turn_at(&path), None);
1569        std::fs::write(&path, b"\xff\xfe\x00 not a rollout \n\xc3\x28\n").unwrap();
1570        assert_eq!(turn_at(&path), None);
1571    }
1572
1573    #[test]
1574    fn a_final_line_still_being_written_is_passed_over() {
1575        let tmp = tempfile::tempdir().unwrap();
1576        let path = tmp.path().join("r.jsonl");
1577        let head_line = head(ID, serde_json::json!("cli"));
1578        // A whole marker whose newline has not landed yet still counts.
1579        std::fs::write(&path, format!("{head_line}\n{}\n{}", started(), complete())).unwrap();
1580        assert_eq!(turn_at(&path), Some(RolloutTurn::Closed));
1581        // Half of one does not, so the marker before it stands.
1582        let done = complete();
1583        let half = &done[..done.len() / 2];
1584        std::fs::write(&path, format!("{head_line}\n{}\n{half}", started())).unwrap();
1585        assert_eq!(turn_at(&path), Some(RolloutTurn::Open));
1586    }
1587
1588    #[test]
1589    fn a_window_starting_mid_line_drops_its_first_fragment() {
1590        let window = format!("{}\n{}\n", complete(), chatter(10));
1591        assert_eq!(
1592            last_turn_marker(window.as_bytes(), false),
1593            Some((RolloutTurn::Closed, 0))
1594        );
1595        // Past the start of the file, the first line is the tail of some other.
1596        assert_eq!(last_turn_marker(window.as_bytes(), true), None);
1597        assert_eq!(
1598            last_turn_marker(b"no newline at all task_started", true),
1599            None
1600        );
1601        // A line that is not UTF-8 is skipped, not fatal.
1602        let mut bytes = b"\xff\xfe\n".to_vec();
1603        bytes.extend_from_slice(started().as_bytes());
1604        assert_eq!(
1605            last_turn_marker(&bytes, false),
1606            Some((RolloutTurn::Open, 3))
1607        );
1608    }
1609
1610    #[test]
1611    fn a_marker_is_located_by_the_offset_of_its_line_in_the_file() {
1612        // The offset is absolute even when the window starts past byte 0, because
1613        // it is how a marker already declined is told from a newer one.
1614        let tmp = tempfile::tempdir().unwrap();
1615        let lines = [
1616            head(ID, serde_json::json!("cli")),
1617            started(),
1618            chatter(300 * 1024),
1619            complete(),
1620        ];
1621        let path = write_rollout(tmp.path(), ID, &lines);
1622        let content = std::fs::read_to_string(&path).unwrap();
1623        let marker = read_last_turn(&path).unwrap().unwrap();
1624        assert_eq!(marker.turn, RolloutTurn::Closed);
1625        assert_eq!(marker.at, content.rfind(&complete()).unwrap() as u64);
1626        assert!(marker.at > TURN_TAIL_START, "found with a mid-file window");
1627        // A window starting at the file's start counts from zero.
1628        std::fs::write(&path, format!("{}\n", started())).unwrap();
1629        assert_eq!(read_last_turn(&path).unwrap().unwrap().at, 0);
1630    }
1631
1632    #[test]
1633    fn the_tail_window_widens_to_find_a_distant_marker() {
1634        // 400 KiB of conversation after the marker: past the first 256 KiB
1635        // window, inside the second.
1636        let mut lines = vec![started()];
1637        lines.extend((0..4).map(|_| chatter(100 * 1024)));
1638        assert_eq!(turn_of(&lines), Some(RolloutTurn::Open));
1639        // One 3 MiB line of tool output after it: the first two windows lie
1640        // wholly inside that line and see no marker at all.
1641        assert_eq!(
1642            turn_of(&[complete(), chatter(3 * 1024 * 1024)]),
1643            Some(RolloutTurn::Closed)
1644        );
1645    }
1646
1647    #[test]
1648    fn a_marker_beyond_the_widest_window_is_unknown() {
1649        let mut lines = vec![started()];
1650        lines.extend((0..50).map(|_| chatter(100 * 1024)));
1651        assert_eq!(turn_of(&lines), None);
1652    }
1653
1654    #[test]
1655    fn a_hookless_session_reads_working_mid_turn_and_idle_after_it() {
1656        // The acceptance criterion of #2135, end to end through the registry.
1657        let tmp = tempfile::tempdir().unwrap();
1658        let path = write_rollout(
1659            tmp.path(),
1660            ID,
1661            &[head(ID, serde_json::json!("cli")), started()],
1662        );
1663        let lock = RefCell::new(LockState::Held);
1664        let registry = SessionsRegistry::new();
1665        let mut state = ScanState::new();
1666        let t0 = SystemTime::now();
1667        let scan_at = |state: &mut ScanState, n: u32| {
1668            scan(
1669                tmp.path(),
1670                tmp.path(),
1671                state,
1672                t0 + WATCH_INTERVAL * n,
1673                &scripted(&lock),
1674            )
1675        };
1676
1677        let first = scan_at(&mut state, 0);
1678        assert_eq!(turns(&first), vec![Some(RolloutTurn::Open)]);
1679        assert_eq!(state_after(&registry, first), SessionState::Working);
1680
1681        // The turn goes on writing; nothing about it has changed.
1682        append(&path, &chatter(64));
1683        assert!(scan_at(&mut state, 1).is_empty());
1684
1685        append(&path, &complete());
1686        let done = scan_at(&mut state, 2);
1687        assert_eq!(turns(&done), vec![Some(RolloutTurn::Closed)]);
1688        assert_eq!(state_after(&registry, done), SessionState::Idle);
1689
1690        // The next turn, and one that is interrupted.
1691        append(&path, &started());
1692        let next = scan_at(&mut state, 3);
1693        assert_eq!(state_after(&registry, next), SessionState::Working);
1694        append(&path, &aborted());
1695        let interrupted = scan_at(&mut state, 4);
1696        assert_eq!(turns(&interrupted), vec![Some(RolloutTurn::Closed)]);
1697        assert_eq!(state_after(&registry, interrupted), SessionState::Idle);
1698    }
1699
1700    #[test]
1701    fn a_closed_turn_on_first_sight_claims_nothing() {
1702        // The first read must not say `idle` over what a hook already reported.
1703        let tmp = tempfile::tempdir().unwrap();
1704        let path = write_rollout(
1705            tmp.path(),
1706            ID,
1707            &[head(ID, serde_json::json!("cli")), started(), complete()],
1708        );
1709        let lock = RefCell::new(LockState::Held);
1710        let mut state = ScanState::new();
1711        let t0 = SystemTime::now();
1712        let first = scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
1713        assert_eq!(turns(&first), vec![None]);
1714        assert_eq!(
1715            track_of(&state, &path).turn.map(|m| m.turn),
1716            Some(RolloutTurn::Closed)
1717        );
1718
1719        let registry = SessionsRegistry::new();
1720        registry.observe(ObserveRequest {
1721            agent_id: None,
1722            pid: None,
1723            agent: Agent::Codex,
1724            session_id: ID.to_string(),
1725            cwd: None,
1726            transcript_path: None,
1727            event: SessionEvent::PreToolUse,
1728            repo: None,
1729            model: None,
1730        });
1731        assert_eq!(state_after(&registry, first), SessionState::Working);
1732
1733        // The next turn is a change, and is reported.
1734        append(&path, &started());
1735        let next = scan(
1736            tmp.path(),
1737            tmp.path(),
1738            &mut state,
1739            t0 + WATCH_INTERVAL,
1740            &scripted(&lock),
1741        );
1742        assert_eq!(turns(&next), vec![Some(RolloutTurn::Open)]);
1743    }
1744
1745    #[test]
1746    fn an_open_turn_in_a_stale_rollout_is_claimed_only_when_a_new_turn_starts() {
1747        // A crash leaves a turn open for good, and `codex resume` of it holds the
1748        // lock again: an old open marker is no evidence a turn is running, and
1749        // writing around it does not make it one. Only a newer marker does.
1750        let tmp = tempfile::tempdir().unwrap();
1751        let path = write_rollout(
1752            tmp.path(),
1753            ID,
1754            &[head(ID, serde_json::json!("cli")), started()],
1755        );
1756        let lock = RefCell::new(LockState::Held);
1757        let mut state = ScanState::new();
1758        let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
1759        let first = scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock));
1760        // Listed (its lock is held), but with no claim about its turn.
1761        assert_eq!(turns(&first), vec![None]);
1762        assert_eq!(track_of(&state, &path).turn, None);
1763        // Written to, but the marker is the same one.
1764        append(&path, &chatter(64));
1765        let grown = scan(
1766            tmp.path(),
1767            tmp.path(),
1768            &mut state,
1769            SystemTime::now(),
1770            &scripted(&lock),
1771        );
1772        assert!(turns(&grown).is_empty());
1773        // A new turn starts: that is evidence.
1774        append(&path, &complete());
1775        append(&path, &started());
1776        let started_again = scan(
1777            tmp.path(),
1778            tmp.path(),
1779            &mut state,
1780            SystemTime::now(),
1781            &scripted(&lock),
1782        );
1783        assert_eq!(turns(&started_again), vec![Some(RolloutTurn::Open)]);
1784    }
1785
1786    #[test]
1787    fn no_turn_is_read_unless_the_lock_is_held() {
1788        let tmp = tempfile::tempdir().unwrap();
1789        let path = write_rollout(
1790            tmp.path(),
1791            ID,
1792            &[head(ID, serde_json::json!("cli")), started()],
1793        );
1794        // A Codex without locks makes no claim about the thread.
1795        let lock = RefCell::new(LockState::Unknown);
1796        let mut state = ScanState::new();
1797        let t0 = SystemTime::now();
1798        let first = scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
1799        assert_eq!(turns(&first), vec![None]);
1800        assert_eq!(track_of(&state, &path).turn_read_at, None);
1801        // Once the lock is held it is read at once, with no growth needed.
1802        *lock.borrow_mut() = LockState::Held;
1803        let held = scan(
1804            tmp.path(),
1805            tmp.path(),
1806            &mut state,
1807            t0 + WATCH_INTERVAL,
1808            &scripted(&lock),
1809        );
1810        assert_eq!(turns(&held), vec![Some(RolloutTurn::Open)]);
1811        // A thread whose lock is already free or gone is not listed at all, so
1812        // its marker is not read.
1813        for gone in [LockState::Free, LockState::Absent] {
1814            let tmp = tempfile::tempdir().unwrap();
1815            let path = write_rollout(
1816                tmp.path(),
1817                ID,
1818                &[head(ID, serde_json::json!("cli")), started()],
1819            );
1820            let lock = RefCell::new(gone);
1821            let mut state = ScanState::new();
1822            assert!(scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock)).is_empty());
1823            assert_eq!(track_of(&state, &path).turn_read_at, None, "{gone:?}");
1824        }
1825    }
1826
1827    #[test]
1828    fn an_unchanged_rollout_is_not_read_again() {
1829        assert_eq!(
1830            started().len(),
1831            aborted().len(),
1832            "the rewrite below keeps the size"
1833        );
1834        let tmp = tempfile::tempdir().unwrap();
1835        let head_line = head(ID, serde_json::json!("cli"));
1836        let path = write_rollout(tmp.path(), ID, &[head_line.clone(), started()]);
1837        let lock = RefCell::new(LockState::Held);
1838        let mut state = ScanState::new();
1839        let t0 = SystemTime::now();
1840        let first = scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
1841        assert_eq!(turns(&first), vec![Some(RolloutTurn::Open)]);
1842        // Rewrite the marker in place at the same size: a re-read would see it
1843        // closed, so silence shows the tail was not read.
1844        std::fs::write(&path, format!("{head_line}\n{}\n", aborted())).unwrap();
1845        let again = scan(
1846            tmp.path(),
1847            tmp.path(),
1848            &mut state,
1849            t0 + WATCH_INTERVAL,
1850            &scripted(&lock),
1851        );
1852        assert!(again.is_empty());
1853    }
1854
1855    #[test]
1856    fn a_turn_change_is_reported_at_once_and_counts_as_the_heartbeat() {
1857        let tmp = tempfile::tempdir().unwrap();
1858        let path = write_rollout(
1859            tmp.path(),
1860            ID,
1861            &[head(ID, serde_json::json!("cli")), started()],
1862        );
1863        let lock = RefCell::new(LockState::Held);
1864        let mut state = ScanState::new();
1865        let t0 = SystemTime::now();
1866        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
1867        append(&path, &complete());
1868        let t1 = t0 + WATCH_INTERVAL;
1869        assert_eq!(
1870            turns(&scan(
1871                tmp.path(),
1872                tmp.path(),
1873                &mut state,
1874                t1,
1875                &scripted(&lock)
1876            )),
1877            vec![Some(RolloutTurn::Closed)],
1878            "not held back for a heartbeat"
1879        );
1880        // A heartbeat from the first report would be due now; from this one it
1881        // is not, because the change was a report.
1882        assert!(scan(
1883            tmp.path(),
1884            tmp.path(),
1885            &mut state,
1886            t0 + HEARTBEAT_INTERVAL,
1887            &scripted(&lock)
1888        )
1889        .is_empty());
1890        let later = scan(
1891            tmp.path(),
1892            tmp.path(),
1893            &mut state,
1894            t1 + HEARTBEAT_INTERVAL,
1895            &scripted(&lock),
1896        );
1897        assert_eq!(turns(&later), vec![None]);
1898    }
1899
1900    #[test]
1901    fn a_marker_out_of_reach_leaves_the_established_turn_alone() {
1902        let tmp = tempfile::tempdir().unwrap();
1903        let path = write_rollout(
1904            tmp.path(),
1905            ID,
1906            &[head(ID, serde_json::json!("cli")), started()],
1907        );
1908        let lock = RefCell::new(LockState::Held);
1909        let mut state = ScanState::new();
1910        let t0 = SystemTime::now();
1911        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
1912        // The turn runs long enough to push its marker beyond the widest window.
1913        for _ in 0..50 {
1914            append(&path, &chatter(100 * 1024));
1915        }
1916        let grown = scan(
1917            tmp.path(),
1918            tmp.path(),
1919            &mut state,
1920            t0 + WATCH_INTERVAL,
1921            &scripted(&lock),
1922        );
1923        assert!(turns(&grown).is_empty(), "unknown changes nothing");
1924        assert_eq!(
1925            track_of(&state, &path).turn.map(|m| m.turn),
1926            Some(RolloutTurn::Open)
1927        );
1928    }
1929
1930    #[test]
1931    fn a_resumed_session_does_not_claim_the_open_turn_it_died_in() {
1932        let tmp = tempfile::tempdir().unwrap();
1933        let path = write_rollout(
1934            tmp.path(),
1935            ID,
1936            &[head(ID, serde_json::json!("cli")), started()],
1937        );
1938        let lock = RefCell::new(LockState::Held);
1939        let mut state = ScanState::new();
1940        let t0 = SystemTime::now();
1941        let live = scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
1942        assert_eq!(turns(&live), vec![Some(RolloutTurn::Open)]);
1943        // The process dies mid-turn: the kernel drops its lock.
1944        *lock.borrow_mut() = LockState::Absent;
1945        let ended = scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
1946        assert_eq!(ids(&ended), vec![format!("end:{ID}")]);
1947        // `codex resume`: the rollout still ends in that open marker, and writing
1948        // around it is not a turn.
1949        *lock.borrow_mut() = LockState::Held;
1950        append(&path, &chatter(64));
1951        let resumed = scan(
1952            tmp.path(),
1953            tmp.path(),
1954            &mut state,
1955            t0 + WATCH_INTERVAL,
1956            &scripted(&lock),
1957        );
1958        assert_eq!(turns(&resumed), vec![None]);
1959        // Its first real turn is.
1960        append(&path, &started());
1961        let next = scan(
1962            tmp.path(),
1963            tmp.path(),
1964            &mut state,
1965            t0 + WATCH_INTERVAL * 2,
1966            &scripted(&lock),
1967        );
1968        assert_eq!(turns(&next), vec![Some(RolloutTurn::Open)]);
1969    }
1970
1971    #[test]
1972    fn a_session_resumed_after_a_finished_turn_starts_fresh() {
1973        let tmp = tempfile::tempdir().unwrap();
1974        let path = write_rollout(
1975            tmp.path(),
1976            ID,
1977            &[head(ID, serde_json::json!("cli")), started(), complete()],
1978        );
1979        let lock = RefCell::new(LockState::Held);
1980        let mut state = ScanState::new();
1981        let t0 = SystemTime::now();
1982        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
1983        *lock.borrow_mut() = LockState::Absent;
1984        scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
1985        *lock.borrow_mut() = LockState::Held;
1986        append(&path, &started());
1987        let resumed = scan(
1988            tmp.path(),
1989            tmp.path(),
1990            &mut state,
1991            t0 + WATCH_INTERVAL,
1992            &scripted(&lock),
1993        );
1994        assert_eq!(turns(&resumed), vec![Some(RolloutTurn::Open)]);
1995    }
1996
1997    #[test]
1998    fn a_failed_read_is_retried_not_remembered_as_done() {
1999        let tmp = tempfile::tempdir().unwrap();
2000        let path = tmp.path().join("rollout.jsonl");
2001        let now = SystemTime::now();
2002        let mut track = SessionTrack {
2003            id: ID.to_string(),
2004            cwd: None,
2005            size: 10,
2006            lock_seen: true,
2007            ended: false,
2008            last_report: SystemTime::UNIX_EPOCH,
2009            turn: None,
2010            turn_read_at: None,
2011            stale_open: None,
2012        };
2013        let rollout = Rollout {
2014            path: &path,
2015            size: 10,
2016            modified: now,
2017        };
2018        // The file is not there to read: nothing is claimed, and nothing is
2019        // remembered, so the same size is read again.
2020        assert_eq!(track.refresh_turn(rollout, LockState::Held, now), None);
2021        assert_eq!(track.turn_read_at, None);
2022        std::fs::write(&path, format!("{}\n", started())).unwrap();
2023        assert_eq!(
2024            track.refresh_turn(rollout, LockState::Held, now),
2025            Some(RolloutTurn::Open)
2026        );
2027        assert_eq!(track.turn_read_at, Some(10));
2028    }
2029
2030    #[test]
2031    fn a_turn_action_applies_as_passive_evidence() {
2032        let registry = SessionsRegistry::new();
2033        let seen = |turn| Action::Seen {
2034            id: ID.to_string(),
2035            cwd: Some(PathBuf::from("/work/repo")),
2036            transcript_path: PathBuf::from("/r.jsonl"),
2037            turn,
2038        };
2039        seen(Some(RolloutTurn::Open)).apply(&registry);
2040        let listed = registry.list();
2041        assert_eq!(listed[0].agent, Agent::Codex);
2042        assert_eq!(listed[0].state, SessionState::Working);
2043        assert_eq!(
2044            listed[0].last_event,
2045            SessionEvent::RolloutTurn(RolloutTurn::Open)
2046        );
2047        // A plain sighting keeps the state; a closed turn ends it.
2048        seen(None).apply(&registry);
2049        assert_eq!(registry.list()[0].state, SessionState::Working);
2050        seen(Some(RolloutTurn::Closed)).apply(&registry);
2051        assert_eq!(registry.list()[0].state, SessionState::Idle);
2052    }
2053}