use std::collections::{HashMap, HashSet};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use serde::Deserialize;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use super::{is_session_uuid, Agent, ObserveRequest, RolloutTurn, SessionEvent, SessionsRegistry};
const UNREAD_PROBE_INTERVAL: Duration = Duration::from_secs(60);
const CODEX_HOME_ENV: &str = "CODEX_HOME";
const WATCH_INTERVAL: Duration = Duration::from_secs(5);
const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(300);
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60);
const MAX_HEAD_BYTES: u64 = 1024 * 1024;
const TURN_TAIL_START: u64 = 256 * 1024;
const TURN_TAIL_MAX: u64 = 4 * 1024 * 1024;
const TURN_TAIL_GROWTH: u64 = 4;
const TURN_MARKER_NAMES: [&str; 3] = ["task_started", "task_complete", "turn_aborted"];
fn codex_home() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os(CODEX_HOME_ENV).filter(|d| !d.is_empty()) {
return Some(PathBuf::from(dir));
}
dirs::home_dir().map(|home| home.join(".codex"))
}
#[derive(Debug, Deserialize)]
struct HeadLine {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
payload: Option<SessionMeta>,
}
#[derive(Debug, Deserialize)]
struct SessionMeta {
#[serde(default)]
id: Option<String>,
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
cwd: Option<PathBuf>,
#[serde(default)]
source: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Head {
Session { id: String, cwd: Option<PathBuf> },
Skip,
Incomplete,
}
fn parse_head(line: &str) -> Head {
let Ok(head) = serde_json::from_str::<HeadLine>(line) else {
return Head::Skip;
};
let Some(meta) = head.payload.filter(|_| head.kind == "session_meta") else {
return Head::Skip;
};
if meta
.source
.as_ref()
.is_some_and(serde_json::Value::is_object)
{
return Head::Skip;
}
match meta
.id
.or(meta.session_id)
.filter(|id| !id.trim().is_empty())
{
Some(id) => Head::Session { id, cwd: meta.cwd },
None => Head::Skip,
}
}
fn read_head(path: &Path) -> Head {
let Ok(file) = std::fs::File::open(path) else {
return Head::Skip;
};
let mut line = String::new();
let mut reader = BufReader::new(file.take(MAX_HEAD_BYTES));
match reader.read_line(&mut line) {
Ok(_) if line.ends_with('\n') => parse_head(&line),
Ok(n) if n as u64 >= MAX_HEAD_BYTES => Head::Skip,
Ok(_) => Head::Incomplete,
Err(_) => Head::Skip,
}
}
#[derive(Debug, Deserialize)]
struct EventLine {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
payload: Option<EventPayload>,
}
#[derive(Debug, Deserialize)]
struct EventPayload {
#[serde(rename = "type", default)]
kind: Option<String>,
}
fn parse_turn_marker(line: &str) -> Option<RolloutTurn> {
if !TURN_MARKER_NAMES.iter().any(|name| line.contains(name)) {
return None;
}
let event = serde_json::from_str::<EventLine>(line).ok()?;
if event.kind != "event_msg" {
return None;
}
match event.payload?.kind?.as_str() {
"task_started" => Some(RolloutTurn::Open),
"task_complete" | "turn_aborted" => Some(RolloutTurn::Closed),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Marker {
turn: RolloutTurn,
at: u64,
}
fn last_turn_marker(window: &[u8], starts_mid_line: bool) -> Option<(RolloutTurn, usize)> {
let first = if starts_mid_line {
window.iter().position(|byte| *byte == b'\n')? + 1
} else {
0
};
let mut end = window.len();
loop {
let begin = window
.get(first..end)?
.iter()
.rposition(|byte| *byte == b'\n')
.map_or(first, |newline| first + newline + 1);
let line = window.get(begin..end)?;
if let Some(turn) = std::str::from_utf8(line).ok().and_then(parse_turn_marker) {
return Some((turn, begin));
}
if begin == first {
return None;
}
end = begin - 1;
}
}
fn read_last_turn(path: &Path) -> std::io::Result<Option<Marker>> {
let mut file = std::fs::File::open(path)?;
let len = file.metadata()?.len();
let mut window = TURN_TAIL_START;
loop {
let start = len.saturating_sub(window);
file.seek(SeekFrom::Start(start))?;
let mut bytes = Vec::new();
(&mut file).take(len - start).read_to_end(&mut bytes)?;
if let Some((turn, begin)) = last_turn_marker(&bytes, start > 0) {
return Ok(Some(Marker {
turn,
at: start + begin as u64,
}));
}
if start == 0 || window >= TURN_TAIL_MAX {
return Ok(None);
}
window = window.saturating_mul(TURN_TAIL_GROWTH).min(TURN_TAIL_MAX);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LockState {
Held,
Free,
Absent,
Unknown,
}
#[cfg(unix)]
fn probe_lock(locks: &Path, id: &str) -> LockState {
use nix::errno::Errno;
use nix::fcntl::{Flock, FlockArg};
let file = match std::fs::File::open(locks.join(format!("{id}.lock"))) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound && locks.is_dir() => {
return LockState::Absent
}
Err(_) => return LockState::Unknown,
};
match Flock::lock(file, FlockArg::LockSharedNonblock) {
Ok(_guard) => LockState::Free,
Err((_, Errno::EWOULDBLOCK)) => LockState::Held,
Err(_) => LockState::Unknown,
}
}
#[cfg(not(unix))]
fn probe_lock(_locks: &Path, _id: &str) -> LockState {
LockState::Unknown
}
pub(crate) fn probe_thread_lock(id: &str) -> LockState {
probe_thread_lock_in(codex_home().as_deref(), id)
}
fn probe_thread_lock_in(home: Option<&Path>, id: &str) -> LockState {
match home {
Some(home) => probe_lock(&home.join("thread-writer-locks"), id),
None => LockState::Unknown,
}
}
#[derive(Debug, Clone)]
enum Tracked {
Unread { size: u64, probed: SystemTime },
Skipped,
Session(SessionTrack),
}
#[derive(Debug, Clone)]
struct SessionTrack {
id: String,
cwd: Option<PathBuf>,
size: u64,
lock_seen: bool,
ended: bool,
last_report: SystemTime,
turn: Option<Marker>,
turn_read_at: Option<u64>,
stale_open: Option<u64>,
}
impl SessionTrack {
fn forget_turn(&mut self) {
if let Some(Marker {
turn: RolloutTurn::Open,
at,
}) = self.turn
{
self.stale_open = Some(at);
}
self.turn = None;
self.turn_read_at = None;
}
fn refresh_turn(
&mut self,
rollout: Rollout<'_>,
lock: LockState,
now: SystemTime,
) -> Option<RolloutTurn> {
if lock != LockState::Held || self.turn_read_at == Some(rollout.size) {
return None;
}
let found = read_last_turn(rollout.path).ok()?;
self.turn_read_at = Some(rollout.size);
let marker = found?;
match (self.turn, marker.turn) {
(None, RolloutTurn::Closed) => {
self.turn = Some(marker);
self.stale_open = None;
None
}
(None, RolloutTurn::Open) if self.stale_open == Some(marker.at) => None,
(None, RolloutTurn::Open) if !is_recent(rollout.modified, now) => {
self.stale_open = Some(marker.at);
None
}
(Some(known), turn) if known.turn == turn => {
self.turn = Some(marker);
None
}
_ => {
self.turn = Some(marker);
self.stale_open = None;
Some(marker.turn)
}
}
}
}
#[derive(Debug, Clone, Copy)]
struct Rollout<'a> {
path: &'a Path,
size: u64,
modified: SystemTime,
}
type ScanState = HashMap<PathBuf, Tracked>;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Action {
Seen {
id: String,
cwd: Option<PathBuf>,
transcript_path: PathBuf,
turn: Option<RolloutTurn>,
},
End { id: String },
}
impl Action {
fn apply(self, registry: &SessionsRegistry) {
match self {
Self::Seen {
id,
cwd,
transcript_path,
turn,
} => registry.observe(ObserveRequest {
agent_id: None,
pid: None,
agent: Agent::Codex,
session_id: id,
cwd,
transcript_path: Some(transcript_path),
event: turn.map_or(
SessionEvent::TranscriptDiscovered,
SessionEvent::RolloutTurn,
),
repo: None,
model: None,
}),
Self::End { id } => {
registry.end(&id, Some("codex rollout watcher"), None);
}
}
}
}
fn is_recent(modified: SystemTime, now: SystemTime) -> bool {
now.duration_since(modified)
.map_or(true, |elapsed| elapsed <= RECENT_ACTIVITY_WINDOW)
}
fn list_rollouts(root: &Path) -> Vec<(PathBuf, u64, SystemTime)> {
let mut out = Vec::new();
let subdirs = |dir: &Path| -> Vec<PathBuf> {
std::fs::read_dir(dir)
.map(|entries| {
entries
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.map(|e| e.path())
.collect()
})
.unwrap_or_default()
};
for year in subdirs(root) {
for month in subdirs(&year) {
for day in subdirs(&month) {
let Ok(files) = std::fs::read_dir(&day) else {
continue;
};
for file in files.flatten() {
let path = file.path();
let is_rollout = path.extension().is_some_and(|e| e == "jsonl")
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("rollout-"));
if !is_rollout {
continue;
}
let Ok(meta) = file.metadata() else {
continue;
};
let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
out.push((path, meta.len(), modified));
}
}
}
}
out
}
fn scan(
sessions: &Path,
locks: &Path,
state: &mut ScanState,
now: SystemTime,
probe: &dyn Fn(&Path, &str) -> LockState,
) -> Vec<Action> {
let mut actions = Vec::new();
let mut present = HashSet::new();
for (path, size, modified) in list_rollouts(sessions) {
present.insert(path.clone());
let recent = is_recent(modified, now);
let rollout = Rollout {
path: &path,
size,
modified,
};
let tracked = match state.remove(&path) {
None if !recent => unread_or_held(rollout, None, locks, now, probe, &mut actions),
None => first_read(rollout, locks, now, probe, &mut actions),
Some(Tracked::Unread { size: old, .. }) if size != old && recent => {
first_read(rollout, locks, now, probe, &mut actions)
}
Some(Tracked::Unread { probed, .. }) => {
unread_or_held(rollout, Some(probed), locks, now, probe, &mut actions)
}
Some(Tracked::Skipped) => Tracked::Skipped,
Some(Tracked::Session(track)) => {
Tracked::Session(rescan(track, rollout, locks, now, probe, &mut actions))
}
};
state.insert(path, tracked);
}
state.retain(|path, tracked| {
if present.contains(path) {
return true;
}
if let Tracked::Session(track) = tracked {
if !track.ended {
actions.push(Action::End {
id: track.id.clone(),
});
}
}
false
});
actions
}
fn thread_id_from_file_name(path: &Path) -> Option<&str> {
let stem = path.file_stem()?.to_str()?;
let split = stem.len().checked_sub(36)?;
let id = stem.get(split..)?;
let separated = split
.checked_sub(1)
.is_some_and(|dash| stem.as_bytes().get(dash) == Some(&b'-'));
(separated && is_session_uuid(id)).then_some(id)
}
fn unread_or_held(
rollout: Rollout<'_>,
probed: Option<SystemTime>,
locks: &Path,
now: SystemTime,
probe: &dyn Fn(&Path, &str) -> LockState,
actions: &mut Vec<Action>,
) -> Tracked {
let size = rollout.size;
if let Some(last) = probed {
if now.duration_since(last).unwrap_or_default() < UNREAD_PROBE_INTERVAL {
return Tracked::Unread { size, probed: last };
}
}
match thread_id_from_file_name(rollout.path) {
Some(id) if probe(locks, id) == LockState::Held => {
first_read(rollout, locks, now, probe, actions)
}
_ => Tracked::Unread { size, probed: now },
}
}
fn first_read(
rollout: Rollout<'_>,
locks: &Path,
now: SystemTime,
probe: &dyn Fn(&Path, &str) -> LockState,
actions: &mut Vec<Action>,
) -> Tracked {
let (id, cwd) = match read_head(rollout.path) {
Head::Skip => return Tracked::Skipped,
Head::Incomplete => {
return Tracked::Unread {
size: rollout.size,
probed: now,
}
}
Head::Session { id, cwd } => (id, cwd),
};
let lock = probe(locks, &id);
let gone = matches!(lock, LockState::Free | LockState::Absent);
let mut track = SessionTrack {
id,
cwd,
size: rollout.size,
lock_seen: lock == LockState::Held,
ended: gone,
last_report: now,
turn: None,
turn_read_at: None,
stale_open: None,
};
if !gone {
let turn = track.refresh_turn(rollout, lock, now);
actions.push(Action::Seen {
id: track.id.clone(),
cwd: track.cwd.clone(),
transcript_path: rollout.path.to_path_buf(),
turn,
});
}
Tracked::Session(track)
}
fn rescan(
mut track: SessionTrack,
rollout: Rollout<'_>,
locks: &Path,
now: SystemTime,
probe: &dyn Fn(&Path, &str) -> LockState,
actions: &mut Vec<Action>,
) -> SessionTrack {
let grew = rollout.size > track.size;
track.size = rollout.size;
if track.ended {
if !grew {
return track;
}
track.ended = false;
track.lock_seen = false;
track.last_report = SystemTime::UNIX_EPOCH;
track.forget_turn();
}
let lock = probe(locks, &track.id);
if lock == LockState::Held {
track.lock_seen = true;
}
if track.lock_seen && matches!(lock, LockState::Free | LockState::Absent) {
track.ended = true;
actions.push(Action::End {
id: track.id.clone(),
});
return track;
}
let turn = track.refresh_turn(rollout, lock, now);
let alive = grew || lock == LockState::Held;
let due = now
.duration_since(track.last_report)
.is_ok_and(|since| since >= HEARTBEAT_INTERVAL);
if turn.is_some() || (alive && due) {
track.last_report = now;
actions.push(Action::Seen {
id: track.id.clone(),
cwd: track.cwd.clone(),
transcript_path: rollout.path.to_path_buf(),
turn,
});
}
track
}
pub fn spawn(registry: Arc<SessionsRegistry>, token: CancellationToken) -> JoinHandle<()> {
tokio::spawn(async move {
let Some(home) = codex_home() else {
tracing::debug!("no Codex home; Codex rollout watcher idle");
token.cancelled().await;
return;
};
let sessions = home.join("sessions");
let locks = home.join("thread-writer-locks");
tracing::debug!("Codex rollout watcher scanning {}", sessions.display());
let mut state = ScanState::new();
loop {
let (scan_sessions, scan_locks) = (sessions.clone(), locks.clone());
let mut owned = std::mem::take(&mut state);
let (returned, actions) = tokio::task::spawn_blocking(move || {
let actions = scan(
&scan_sessions,
&scan_locks,
&mut owned,
SystemTime::now(),
&probe_lock,
);
(owned, actions)
})
.await
.unwrap_or_else(|_| (ScanState::new(), Vec::new()));
state = returned;
for action in actions {
action.apply(®istry);
}
tokio::select! {
() = token.cancelled() => break,
() = tokio::time::sleep(WATCH_INTERVAL) => {}
}
}
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::io::Write;
use crate::sessions::SessionState;
const ID: &str = "019a0000-0000-7000-8000-00000000c0de";
fn head(id: &str, source: serde_json::Value) -> String {
serde_json::json!({
"timestamp": "2026-09-25T00:00:00Z",
"type": "session_meta",
"payload": {
"id": id,
"session_id": id,
"cwd": "/work/repo",
"originator": "codex-tui",
"source": source,
"base_instructions": { "text": "x".repeat(2048) },
},
})
.to_string()
}
fn write_rollout(root: &Path, id: &str, lines: &[String]) -> PathBuf {
let dir = root.join("2026").join("09").join("25");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("rollout-2026-09-25T00-00-00-{id}.jsonl"));
let mut f = std::fs::File::create(&path).unwrap();
for line in lines {
writeln!(f, "{line}").unwrap();
}
path
}
fn append(path: &Path, line: &str) {
let mut f = std::fs::OpenOptions::new().append(true).open(path).unwrap();
writeln!(f, "{line}").unwrap();
}
fn scripted(cell: &RefCell<LockState>) -> impl Fn(&Path, &str) -> LockState + '_ {
move |_, _| *cell.borrow()
}
fn ids(actions: &[Action]) -> Vec<String> {
actions
.iter()
.map(|a| match a {
Action::Seen { id, .. } => format!("seen:{id}"),
Action::End { id } => format!("end:{id}"),
})
.collect()
}
#[test]
fn parse_head_reads_a_session_and_skips_subagents_and_junk() {
assert_eq!(
parse_head(&head(ID, serde_json::json!("cli"))),
Head::Session {
id: ID.to_string(),
cwd: Some(PathBuf::from("/work/repo")),
}
);
let subagent = serde_json::json!({ "subagent": { "thread_spawn": {} } });
assert_eq!(parse_head(&head(ID, subagent)), Head::Skip);
assert_eq!(parse_head("not json"), Head::Skip);
assert_eq!(
parse_head(r#"{"type":"response_item","payload":{"id":"x"}}"#),
Head::Skip
);
assert_eq!(
parse_head(r#"{"type":"session_meta","payload":{"cwd":"/w"}}"#),
Head::Skip
);
assert_eq!(
parse_head(r#"{"type":"session_meta","payload":{"session_id":"s"}}"#),
Head::Session {
id: "s".to_string(),
cwd: None
}
);
}
#[test]
fn read_head_needs_a_complete_first_line() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("r.jsonl");
std::fs::write(&path, head(ID, serde_json::json!("vscode"))).unwrap();
assert_eq!(read_head(&path), Head::Incomplete);
std::fs::write(&path, "").unwrap();
assert_eq!(read_head(&path), Head::Incomplete);
std::fs::write(
&path,
format!("{}\n{{}}\n", head(ID, serde_json::json!("vscode"))),
)
.unwrap();
assert!(matches!(read_head(&path), Head::Session { .. }));
assert_eq!(read_head(&tmp.path().join("missing")), Head::Skip);
}
#[test]
fn a_recent_rollout_is_discovered_once_with_its_cwd() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let lock = RefCell::new(LockState::Unknown);
let mut state = ScanState::new();
let now = SystemTime::now();
let first = scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock));
assert_eq!(
first,
vec![Action::Seen {
id: ID.to_string(),
cwd: Some(PathBuf::from("/work/repo")),
transcript_path: path,
turn: None,
}]
);
assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
}
#[test]
fn subagent_rollouts_and_non_rollout_files_are_never_reported() {
let tmp = tempfile::tempdir().unwrap();
let sub = serde_json::json!({ "subagent": "review" });
write_rollout(tmp.path(), "child", &[head("child", sub)]);
let dir = tmp.path().join("2026/09/25");
std::fs::write(dir.join("notes.jsonl"), "{}\n").unwrap();
std::fs::write(tmp.path().join("rollout-loose.jsonl"), "{}\n").unwrap();
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let now = SystemTime::now();
assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
let later = now + HEARTBEAT_INTERVAL * 2;
assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
}
#[test]
fn an_old_rollout_is_recorded_silently_and_read_when_it_grows() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let lock = RefCell::new(LockState::Unknown);
let mut state = ScanState::new();
let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
assert!(matches!(state[&path], Tracked::Unread { .. }));
append(&path, "{}");
let now = SystemTime::now();
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
now,
&scripted(&lock)
)),
vec![format!("seen:{ID}")]
);
}
#[test]
fn an_old_rollout_whose_lock_is_held_is_announced_regardless_of_its_age() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
assert_eq!(
scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)),
vec![Action::Seen {
id: ID.to_string(),
cwd: Some(PathBuf::from("/work/repo")),
transcript_path: path.clone(),
turn: None,
}]
);
assert!(matches!(state[&path], Tracked::Session(_)));
assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
*lock.borrow_mut() = LockState::Free;
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
far,
&scripted(&lock)
)),
vec![format!("end:{ID}")]
);
}
#[test]
fn an_old_rollout_is_announced_when_its_lock_becomes_held_later() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let lock = RefCell::new(LockState::Absent);
let mut state = ScanState::new();
let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
assert!(matches!(state[&path], Tracked::Unread { .. }));
*lock.borrow_mut() = LockState::Held;
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
far + UNREAD_PROBE_INTERVAL,
&scripted(&lock)
)),
vec![format!("seen:{ID}")]
);
}
#[test]
fn unread_rollouts_are_probed_once_per_interval_not_every_scan() {
let tmp = tempfile::tempdir().unwrap();
write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let probes = std::cell::Cell::new(0_u32);
let counting = |_: &Path, _: &str| {
probes.set(probes.get() + 1);
LockState::Absent
};
let mut state = ScanState::new();
let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
scan(tmp.path(), tmp.path(), &mut state, far, &counting);
assert_eq!(probes.get(), 1, "probed on first sight");
for scans in 1..=10 {
scan(
tmp.path(),
tmp.path(),
&mut state,
far + WATCH_INTERVAL * scans,
&counting,
);
}
assert_eq!(probes.get(), 1, "not again within the interval");
scan(
tmp.path(),
tmp.path(),
&mut state,
far + UNREAD_PROBE_INTERVAL,
&counting,
);
assert_eq!(probes.get(), 2, "once the interval has passed");
}
#[test]
fn an_old_rollout_with_a_free_or_unknown_lock_stays_unread() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
for held in [LockState::Free, LockState::Absent, LockState::Unknown] {
let lock = RefCell::new(held);
let mut state = ScanState::new();
assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
assert!(matches!(state[&path], Tracked::Unread { .. }), "{held:?}");
}
}
#[test]
fn an_old_subagent_rollout_with_a_held_lock_is_skipped_not_announced() {
let tmp = tempfile::tempdir().unwrap();
let sub = serde_json::json!({ "subagent": "review" });
let path = write_rollout(tmp.path(), ID, &[head(ID, sub)]);
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
assert!(matches!(state[&path], Tracked::Skipped));
}
#[test]
fn thread_ids_come_only_from_a_uuid_ending_the_file_name() {
let named = |name: &str| thread_id_from_file_name(Path::new(name)).map(str::to_string);
assert_eq!(
named(&format!("rollout-2026-09-25T00-00-00-{ID}.jsonl")),
Some(ID.to_string())
);
assert_eq!(named("rollout-2026-09-25T00-00-00-child.jsonl"), None);
assert_eq!(named("x.jsonl"), None);
assert_eq!(
named("rollout-019a0000-0000-7000-8000-00000000zzzz.jsonl"),
None
);
assert_eq!(named(&format!("rollout{ID}.jsonl")), None);
assert_eq!(named(&format!("{ID}.jsonl")), None);
}
#[test]
fn a_held_lock_heartbeats_and_its_release_ends_the_session() {
let tmp = tempfile::tempdir().unwrap();
write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let t0 = SystemTime::now();
assert_eq!(
scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock)).len(),
1
);
let t1 = t0 + WATCH_INTERVAL;
assert!(scan(tmp.path(), tmp.path(), &mut state, t1, &scripted(&lock)).is_empty());
let t2 = t0 + HEARTBEAT_INTERVAL;
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
t2,
&scripted(&lock)
)),
vec![format!("seen:{ID}")]
);
*lock.borrow_mut() = LockState::Free;
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
t2,
&scripted(&lock)
)),
vec![format!("end:{ID}")]
);
let t3 = t2 + HEARTBEAT_INTERVAL;
assert!(scan(tmp.path(), tmp.path(), &mut state, t3, &scripted(&lock)).is_empty());
}
#[test]
fn a_lock_never_seen_held_does_not_end_the_session() {
let tmp = tempfile::tempdir().unwrap();
write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let lock = RefCell::new(LockState::Unknown);
let mut state = ScanState::new();
let t0 = SystemTime::now();
scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
let later = t0 + HEARTBEAT_INTERVAL * 3;
assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
*lock.borrow_mut() = LockState::Held;
scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock));
*lock.borrow_mut() = LockState::Unknown;
let actions = scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock));
assert!(!ids(&actions).iter().any(|a| a.starts_with("end:")));
}
#[test]
fn growth_without_a_lock_is_a_heartbeat_not_a_state() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("exec"))]);
let lock = RefCell::new(LockState::Unknown);
let mut state = ScanState::new();
let t0 = SystemTime::now();
scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
append(&path, "{}");
let due = t0 + HEARTBEAT_INTERVAL;
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
due,
&scripted(&lock)
)),
vec![format!("seen:{ID}")]
);
}
#[test]
fn an_archived_rollout_ends_its_session_once() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("vscode"))]);
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let now = SystemTime::now();
scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock));
let archived = tmp.path().join("archived.jsonl");
std::fs::rename(&path, archived).unwrap();
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
now,
&scripted(&lock)
)),
vec![format!("end:{ID}")]
);
assert!(state.is_empty());
assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
}
#[test]
fn a_resumed_session_is_reported_again_after_an_end() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let t0 = SystemTime::now();
scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
*lock.borrow_mut() = LockState::Absent;
scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
*lock.borrow_mut() = LockState::Held;
append(&path, "{}");
let due = t0 + HEARTBEAT_INTERVAL;
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
due,
&scripted(&lock)
)),
vec![format!("seen:{ID}")]
);
}
#[test]
fn a_head_still_being_written_is_read_once_it_is_complete() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("2026/09/25");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("rollout-2026-09-25T00-00-00-{ID}.jsonl"));
let full = head(ID, serde_json::json!("cli"));
std::fs::write(&path, &full[..full.len() / 2]).unwrap();
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let now = SystemTime::now();
assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
assert!(matches!(state[&path], Tracked::Unread { .. }));
std::fs::write(&path, format!("{full}\n")).unwrap();
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
now,
&scripted(&lock)
)),
vec![format!("seen:{ID}")]
);
}
#[test]
fn a_restart_does_not_list_a_session_whose_lock_is_already_released() {
for gone in [LockState::Free, LockState::Absent] {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
let lock = RefCell::new(gone);
let mut state = ScanState::new();
let now = SystemTime::now();
assert!(
scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty(),
"{gone:?}"
);
let later = now + HEARTBEAT_INTERVAL * 2;
assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
*lock.borrow_mut() = LockState::Held;
append(&path, "{}");
assert_eq!(
ids(&scan(
tmp.path(),
tmp.path(),
&mut state,
later,
&scripted(&lock)
)),
vec![format!("seen:{ID}")]
);
}
}
#[test]
fn scanning_a_missing_root_is_empty() {
let lock = RefCell::new(LockState::Unknown);
let mut state = ScanState::new();
let missing = Path::new("/nonexistent/codex/sessions");
assert!(scan(
missing,
missing,
&mut state,
SystemTime::now(),
&scripted(&lock)
)
.is_empty());
}
#[cfg(unix)]
#[test]
fn probe_lock_tells_held_from_free_and_absent() {
use nix::fcntl::{Flock, FlockArg};
let tmp = tempfile::tempdir().unwrap();
assert_eq!(
probe_lock(&tmp.path().join("missing"), ID),
LockState::Unknown
);
assert_eq!(probe_lock(tmp.path(), ID), LockState::Absent);
let path = tmp.path().join(format!("{ID}.lock"));
std::fs::write(&path, "").unwrap();
assert_eq!(probe_lock(tmp.path(), ID), LockState::Free);
let holder = Flock::lock(
std::fs::File::open(&path).unwrap(),
FlockArg::LockExclusiveNonblock,
)
.unwrap();
assert_eq!(probe_lock(tmp.path(), ID), LockState::Held);
assert_eq!(probe_lock(tmp.path(), ID), LockState::Held);
drop(holder);
assert_eq!(probe_lock(tmp.path(), ID), LockState::Free);
}
#[test]
fn the_thread_lock_probe_reads_the_resolved_homes_lock_directory() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(probe_thread_lock_in(None, ID), LockState::Unknown);
assert_eq!(
probe_thread_lock_in(Some(tmp.path()), ID),
LockState::Unknown
);
let locks = tmp.path().join("thread-writer-locks");
std::fs::create_dir(&locks).unwrap();
assert_eq!(
probe_thread_lock_in(Some(tmp.path()), ID),
LockState::Absent
);
std::fs::write(locks.join(format!("{ID}.lock")), "").unwrap();
assert_eq!(probe_thread_lock_in(Some(tmp.path()), ID), LockState::Free);
}
#[test]
fn actions_apply_to_the_registry_as_codex_sessions() {
let registry = SessionsRegistry::new();
Action::Seen {
id: ID.to_string(),
cwd: Some(PathBuf::from("/work/repo")),
transcript_path: PathBuf::from("/r.jsonl"),
turn: None,
}
.apply(®istry);
let listed = registry.list();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].agent, Agent::Codex);
assert_eq!(listed[0].state, SessionState::Idle);
assert_eq!(listed[0].cwd.as_deref(), Some(Path::new("/work/repo")));
Action::End { id: ID.to_string() }.apply(®istry);
assert_eq!(registry.list()[0].state, SessionState::Ended);
}
fn marker(kind: &str) -> String {
serde_json::json!({
"timestamp": "2026-09-25T00:00:01Z",
"type": "event_msg",
"payload": { "type": kind, "turn_id": "turn-1" },
})
.to_string()
}
fn started() -> String {
marker("task_started")
}
fn complete() -> String {
marker("task_complete")
}
fn aborted() -> String {
marker("turn_aborted")
}
fn chatter(bytes: usize) -> String {
serde_json::json!({
"timestamp": "2026-09-25T00:00:02Z",
"type": "response_item",
"payload": { "type": "message", "role": "assistant", "content": "x".repeat(bytes) },
})
.to_string()
}
fn quoting() -> String {
serde_json::json!({
"type": "response_item",
"payload": { "type": "message", "content": "task_started task_complete turn_aborted" },
})
.to_string()
}
fn turn_at(path: &Path) -> Option<RolloutTurn> {
read_last_turn(path).unwrap().map(|marker| marker.turn)
}
fn turn_of(lines: &[String]) -> Option<RolloutTurn> {
let tmp = tempfile::tempdir().unwrap();
let mut all = vec![head(ID, serde_json::json!("cli"))];
all.extend_from_slice(lines);
turn_at(&write_rollout(tmp.path(), ID, &all))
}
fn turns(actions: &[Action]) -> Vec<Option<RolloutTurn>> {
actions
.iter()
.filter_map(|a| match a {
Action::Seen { turn, .. } => Some(*turn),
Action::End { .. } => None,
})
.collect()
}
fn track_of<'a>(state: &'a ScanState, path: &Path) -> &'a SessionTrack {
match &state[path] {
Tracked::Session(track) => track,
other => panic!("not a session: {other:?}"),
}
}
fn state_after(registry: &SessionsRegistry, actions: Vec<Action>) -> SessionState {
for action in actions {
action.apply(registry);
}
registry.list()[0].state
}
#[test]
fn only_an_event_msg_marker_counts_as_a_turn_marker() {
assert_eq!(parse_turn_marker(&started()), Some(RolloutTurn::Open));
assert_eq!(parse_turn_marker(&complete()), Some(RolloutTurn::Closed));
assert_eq!(parse_turn_marker(&aborted()), Some(RolloutTurn::Closed));
assert_eq!(parse_turn_marker("ing()), None);
let other = serde_json::json!({
"type": "event_msg",
"payload": { "type": "agent_message", "message": "task_started" },
});
assert_eq!(parse_turn_marker(&other.to_string()), None);
for line in [
"task_started",
"",
r#"{"type":"event_msg"}"#,
r#"{"type":"event_msg","payload":"task_started"}"#,
r#"{"type":"event_msg","payload":{"n":"task_started"}}"#,
r#"{"type":"event_msg","payload":{"type":7,"n":"task_started"}}"#,
r#"{"type":5,"payload":{"type":"task_started"}}"#,
] {
assert_eq!(parse_turn_marker(line), None, "{line}");
}
}
#[test]
fn the_last_marker_wins_whatever_the_counts() {
let cases: Vec<(Vec<String>, Option<RolloutTurn>)> = vec![
(vec![started()], Some(RolloutTurn::Open)),
(vec![started(), complete()], Some(RolloutTurn::Closed)),
(vec![started(), aborted()], Some(RolloutTurn::Closed)),
(
vec![started(), complete(), started()],
Some(RolloutTurn::Open),
),
(
vec![started(), complete(), complete()],
Some(RolloutTurn::Closed),
),
(
vec![started(), chatter(10), quoting(), chatter(10)],
Some(RolloutTurn::Open),
),
(vec![chatter(10), quoting()], None),
(vec![], None),
];
for (lines, expected) in cases {
assert_eq!(turn_of(&lines), expected, "{lines:?}");
}
}
#[test]
fn an_unreadable_rollout_is_an_error_and_a_markerless_one_is_not() {
assert!(read_last_turn(Path::new("/nonexistent/rollout.jsonl")).is_err());
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("r.jsonl");
std::fs::write(&path, b"").unwrap();
assert_eq!(turn_at(&path), None);
std::fs::write(&path, b"\xff\xfe\x00 not a rollout \n\xc3\x28\n").unwrap();
assert_eq!(turn_at(&path), None);
}
#[test]
fn a_final_line_still_being_written_is_passed_over() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("r.jsonl");
let head_line = head(ID, serde_json::json!("cli"));
std::fs::write(&path, format!("{head_line}\n{}\n{}", started(), complete())).unwrap();
assert_eq!(turn_at(&path), Some(RolloutTurn::Closed));
let done = complete();
let half = &done[..done.len() / 2];
std::fs::write(&path, format!("{head_line}\n{}\n{half}", started())).unwrap();
assert_eq!(turn_at(&path), Some(RolloutTurn::Open));
}
#[test]
fn a_window_starting_mid_line_drops_its_first_fragment() {
let window = format!("{}\n{}\n", complete(), chatter(10));
assert_eq!(
last_turn_marker(window.as_bytes(), false),
Some((RolloutTurn::Closed, 0))
);
assert_eq!(last_turn_marker(window.as_bytes(), true), None);
assert_eq!(
last_turn_marker(b"no newline at all task_started", true),
None
);
let mut bytes = b"\xff\xfe\n".to_vec();
bytes.extend_from_slice(started().as_bytes());
assert_eq!(
last_turn_marker(&bytes, false),
Some((RolloutTurn::Open, 3))
);
}
#[test]
fn a_marker_is_located_by_the_offset_of_its_line_in_the_file() {
let tmp = tempfile::tempdir().unwrap();
let lines = [
head(ID, serde_json::json!("cli")),
started(),
chatter(300 * 1024),
complete(),
];
let path = write_rollout(tmp.path(), ID, &lines);
let content = std::fs::read_to_string(&path).unwrap();
let marker = read_last_turn(&path).unwrap().unwrap();
assert_eq!(marker.turn, RolloutTurn::Closed);
assert_eq!(marker.at, content.rfind(&complete()).unwrap() as u64);
assert!(marker.at > TURN_TAIL_START, "found with a mid-file window");
std::fs::write(&path, format!("{}\n", started())).unwrap();
assert_eq!(read_last_turn(&path).unwrap().unwrap().at, 0);
}
#[test]
fn the_tail_window_widens_to_find_a_distant_marker() {
let mut lines = vec![started()];
lines.extend((0..4).map(|_| chatter(100 * 1024)));
assert_eq!(turn_of(&lines), Some(RolloutTurn::Open));
assert_eq!(
turn_of(&[complete(), chatter(3 * 1024 * 1024)]),
Some(RolloutTurn::Closed)
);
}
#[test]
fn a_marker_beyond_the_widest_window_is_unknown() {
let mut lines = vec![started()];
lines.extend((0..50).map(|_| chatter(100 * 1024)));
assert_eq!(turn_of(&lines), None);
}
#[test]
fn a_hookless_session_reads_working_mid_turn_and_idle_after_it() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(
tmp.path(),
ID,
&[head(ID, serde_json::json!("cli")), started()],
);
let lock = RefCell::new(LockState::Held);
let registry = SessionsRegistry::new();
let mut state = ScanState::new();
let t0 = SystemTime::now();
let scan_at = |state: &mut ScanState, n: u32| {
scan(
tmp.path(),
tmp.path(),
state,
t0 + WATCH_INTERVAL * n,
&scripted(&lock),
)
};
let first = scan_at(&mut state, 0);
assert_eq!(turns(&first), vec![Some(RolloutTurn::Open)]);
assert_eq!(state_after(®istry, first), SessionState::Working);
append(&path, &chatter(64));
assert!(scan_at(&mut state, 1).is_empty());
append(&path, &complete());
let done = scan_at(&mut state, 2);
assert_eq!(turns(&done), vec![Some(RolloutTurn::Closed)]);
assert_eq!(state_after(®istry, done), SessionState::Idle);
append(&path, &started());
let next = scan_at(&mut state, 3);
assert_eq!(state_after(®istry, next), SessionState::Working);
append(&path, &aborted());
let interrupted = scan_at(&mut state, 4);
assert_eq!(turns(&interrupted), vec![Some(RolloutTurn::Closed)]);
assert_eq!(state_after(®istry, interrupted), SessionState::Idle);
}
#[test]
fn a_closed_turn_on_first_sight_claims_nothing() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(
tmp.path(),
ID,
&[head(ID, serde_json::json!("cli")), started(), complete()],
);
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let t0 = SystemTime::now();
let first = scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
assert_eq!(turns(&first), vec![None]);
assert_eq!(
track_of(&state, &path).turn.map(|m| m.turn),
Some(RolloutTurn::Closed)
);
let registry = SessionsRegistry::new();
registry.observe(ObserveRequest {
agent_id: None,
pid: None,
agent: Agent::Codex,
session_id: ID.to_string(),
cwd: None,
transcript_path: None,
event: SessionEvent::PreToolUse,
repo: None,
model: None,
});
assert_eq!(state_after(®istry, first), SessionState::Working);
append(&path, &started());
let next = scan(
tmp.path(),
tmp.path(),
&mut state,
t0 + WATCH_INTERVAL,
&scripted(&lock),
);
assert_eq!(turns(&next), vec![Some(RolloutTurn::Open)]);
}
#[test]
fn an_open_turn_in_a_stale_rollout_is_claimed_only_when_a_new_turn_starts() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(
tmp.path(),
ID,
&[head(ID, serde_json::json!("cli")), started()],
);
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
let first = scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock));
assert_eq!(turns(&first), vec![None]);
assert_eq!(track_of(&state, &path).turn, None);
append(&path, &chatter(64));
let grown = scan(
tmp.path(),
tmp.path(),
&mut state,
SystemTime::now(),
&scripted(&lock),
);
assert!(turns(&grown).is_empty());
append(&path, &complete());
append(&path, &started());
let started_again = scan(
tmp.path(),
tmp.path(),
&mut state,
SystemTime::now(),
&scripted(&lock),
);
assert_eq!(turns(&started_again), vec![Some(RolloutTurn::Open)]);
}
#[test]
fn no_turn_is_read_unless_the_lock_is_held() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(
tmp.path(),
ID,
&[head(ID, serde_json::json!("cli")), started()],
);
let lock = RefCell::new(LockState::Unknown);
let mut state = ScanState::new();
let t0 = SystemTime::now();
let first = scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
assert_eq!(turns(&first), vec![None]);
assert_eq!(track_of(&state, &path).turn_read_at, None);
*lock.borrow_mut() = LockState::Held;
let held = scan(
tmp.path(),
tmp.path(),
&mut state,
t0 + WATCH_INTERVAL,
&scripted(&lock),
);
assert_eq!(turns(&held), vec![Some(RolloutTurn::Open)]);
for gone in [LockState::Free, LockState::Absent] {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(
tmp.path(),
ID,
&[head(ID, serde_json::json!("cli")), started()],
);
let lock = RefCell::new(gone);
let mut state = ScanState::new();
assert!(scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock)).is_empty());
assert_eq!(track_of(&state, &path).turn_read_at, None, "{gone:?}");
}
}
#[test]
fn an_unchanged_rollout_is_not_read_again() {
assert_eq!(
started().len(),
aborted().len(),
"the rewrite below keeps the size"
);
let tmp = tempfile::tempdir().unwrap();
let head_line = head(ID, serde_json::json!("cli"));
let path = write_rollout(tmp.path(), ID, &[head_line.clone(), started()]);
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let t0 = SystemTime::now();
let first = scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
assert_eq!(turns(&first), vec![Some(RolloutTurn::Open)]);
std::fs::write(&path, format!("{head_line}\n{}\n", aborted())).unwrap();
let again = scan(
tmp.path(),
tmp.path(),
&mut state,
t0 + WATCH_INTERVAL,
&scripted(&lock),
);
assert!(again.is_empty());
}
#[test]
fn a_turn_change_is_reported_at_once_and_counts_as_the_heartbeat() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(
tmp.path(),
ID,
&[head(ID, serde_json::json!("cli")), started()],
);
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let t0 = SystemTime::now();
scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
append(&path, &complete());
let t1 = t0 + WATCH_INTERVAL;
assert_eq!(
turns(&scan(
tmp.path(),
tmp.path(),
&mut state,
t1,
&scripted(&lock)
)),
vec![Some(RolloutTurn::Closed)],
"not held back for a heartbeat"
);
assert!(scan(
tmp.path(),
tmp.path(),
&mut state,
t0 + HEARTBEAT_INTERVAL,
&scripted(&lock)
)
.is_empty());
let later = scan(
tmp.path(),
tmp.path(),
&mut state,
t1 + HEARTBEAT_INTERVAL,
&scripted(&lock),
);
assert_eq!(turns(&later), vec![None]);
}
#[test]
fn a_marker_out_of_reach_leaves_the_established_turn_alone() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(
tmp.path(),
ID,
&[head(ID, serde_json::json!("cli")), started()],
);
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let t0 = SystemTime::now();
scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
for _ in 0..50 {
append(&path, &chatter(100 * 1024));
}
let grown = scan(
tmp.path(),
tmp.path(),
&mut state,
t0 + WATCH_INTERVAL,
&scripted(&lock),
);
assert!(turns(&grown).is_empty(), "unknown changes nothing");
assert_eq!(
track_of(&state, &path).turn.map(|m| m.turn),
Some(RolloutTurn::Open)
);
}
#[test]
fn a_resumed_session_does_not_claim_the_open_turn_it_died_in() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(
tmp.path(),
ID,
&[head(ID, serde_json::json!("cli")), started()],
);
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let t0 = SystemTime::now();
let live = scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
assert_eq!(turns(&live), vec![Some(RolloutTurn::Open)]);
*lock.borrow_mut() = LockState::Absent;
let ended = scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
assert_eq!(ids(&ended), vec![format!("end:{ID}")]);
*lock.borrow_mut() = LockState::Held;
append(&path, &chatter(64));
let resumed = scan(
tmp.path(),
tmp.path(),
&mut state,
t0 + WATCH_INTERVAL,
&scripted(&lock),
);
assert_eq!(turns(&resumed), vec![None]);
append(&path, &started());
let next = scan(
tmp.path(),
tmp.path(),
&mut state,
t0 + WATCH_INTERVAL * 2,
&scripted(&lock),
);
assert_eq!(turns(&next), vec![Some(RolloutTurn::Open)]);
}
#[test]
fn a_session_resumed_after_a_finished_turn_starts_fresh() {
let tmp = tempfile::tempdir().unwrap();
let path = write_rollout(
tmp.path(),
ID,
&[head(ID, serde_json::json!("cli")), started(), complete()],
);
let lock = RefCell::new(LockState::Held);
let mut state = ScanState::new();
let t0 = SystemTime::now();
scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
*lock.borrow_mut() = LockState::Absent;
scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
*lock.borrow_mut() = LockState::Held;
append(&path, &started());
let resumed = scan(
tmp.path(),
tmp.path(),
&mut state,
t0 + WATCH_INTERVAL,
&scripted(&lock),
);
assert_eq!(turns(&resumed), vec![Some(RolloutTurn::Open)]);
}
#[test]
fn a_failed_read_is_retried_not_remembered_as_done() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("rollout.jsonl");
let now = SystemTime::now();
let mut track = SessionTrack {
id: ID.to_string(),
cwd: None,
size: 10,
lock_seen: true,
ended: false,
last_report: SystemTime::UNIX_EPOCH,
turn: None,
turn_read_at: None,
stale_open: None,
};
let rollout = Rollout {
path: &path,
size: 10,
modified: now,
};
assert_eq!(track.refresh_turn(rollout, LockState::Held, now), None);
assert_eq!(track.turn_read_at, None);
std::fs::write(&path, format!("{}\n", started())).unwrap();
assert_eq!(
track.refresh_turn(rollout, LockState::Held, now),
Some(RolloutTurn::Open)
);
assert_eq!(track.turn_read_at, Some(10));
}
#[test]
fn a_turn_action_applies_as_passive_evidence() {
let registry = SessionsRegistry::new();
let seen = |turn| Action::Seen {
id: ID.to_string(),
cwd: Some(PathBuf::from("/work/repo")),
transcript_path: PathBuf::from("/r.jsonl"),
turn,
};
seen(Some(RolloutTurn::Open)).apply(®istry);
let listed = registry.list();
assert_eq!(listed[0].agent, Agent::Codex);
assert_eq!(listed[0].state, SessionState::Working);
assert_eq!(
listed[0].last_event,
SessionEvent::RolloutTurn(RolloutTurn::Open)
);
seen(None).apply(®istry);
assert_eq!(registry.list()[0].state, SessionState::Working);
seen(Some(RolloutTurn::Closed)).apply(®istry);
assert_eq!(registry.list()[0].state, SessionState::Idle);
}
}