1use 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
93const UNREAD_PROBE_INTERVAL: Duration = Duration::from_secs(60);
99
100const CODEX_HOME_ENV: &str = "CODEX_HOME";
102
103const WATCH_INTERVAL: Duration = Duration::from_secs(5);
105
106const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(300);
110
111const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60);
114
115const MAX_HEAD_BYTES: u64 = 1024 * 1024;
119
120const TURN_TAIL_START: u64 = 256 * 1024;
125
126const TURN_TAIL_MAX: u64 = 4 * 1024 * 1024;
131
132const TURN_TAIL_GROWTH: u64 = 4;
134
135const TURN_MARKER_NAMES: [&str; 3] = ["task_started", "task_complete", "turn_aborted"];
139
140fn 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#[derive(Debug, Deserialize)]
151struct HeadLine {
152 #[serde(rename = "type")]
153 kind: String,
154 #[serde(default)]
155 payload: Option<SessionMeta>,
156}
157
158#[derive(Debug, Deserialize)]
160struct SessionMeta {
161 #[serde(default)]
164 id: Option<String>,
165 #[serde(default)]
166 session_id: Option<String>,
167 #[serde(default)]
168 cwd: Option<PathBuf>,
169 #[serde(default)]
172 source: Option<serde_json::Value>,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
177enum Head {
178 Session { id: String, cwd: Option<PathBuf> },
180 Skip,
182 Incomplete,
185}
186
187fn 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
213fn 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 Ok(n) if n as u64 >= MAX_HEAD_BYTES => Head::Skip,
224 Ok(_) => Head::Incomplete,
225 Err(_) => Head::Skip,
226 }
227}
228
229#[derive(Debug, Deserialize)]
232struct EventLine {
233 #[serde(rename = "type")]
234 kind: String,
235 #[serde(default)]
236 payload: Option<EventPayload>,
237}
238
239#[derive(Debug, Deserialize)]
241struct EventPayload {
242 #[serde(rename = "type", default)]
243 kind: Option<String>,
244}
245
246fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266struct Marker {
267 turn: RolloutTurn,
268 at: u64,
272}
273
274fn 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
303fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub(crate) enum LockState {
333 Held,
335 Free,
337 Absent,
339 Unknown,
341}
342
343#[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 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 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
372pub(crate) fn probe_thread_lock(id: &str) -> LockState {
377 probe_thread_lock_in(codex_home().as_deref(), id)
378}
379
380fn 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#[derive(Debug, Clone)]
390enum Tracked {
391 Unread { size: u64, probed: SystemTime },
395 Skipped,
397 Session(SessionTrack),
399}
400
401#[derive(Debug, Clone)]
403struct SessionTrack {
404 id: String,
405 cwd: Option<PathBuf>,
406 size: u64,
407 lock_seen: bool,
411 ended: bool,
414 last_report: SystemTime,
416 turn: Option<Marker>,
419 turn_read_at: Option<u64>,
423 stale_open: Option<u64>,
426}
427
428impl SessionTrack {
429 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 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 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 (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#[derive(Debug, Clone, Copy)]
503struct Rollout<'a> {
504 path: &'a Path,
505 size: u64,
506 modified: SystemTime,
507}
508
509type ScanState = HashMap<PathBuf, Tracked>;
511
512#[derive(Debug, Clone, PartialEq, Eq)]
514enum Action {
515 Seen {
518 id: String,
519 cwd: Option<PathBuf>,
520 transcript_path: PathBuf,
521 turn: Option<RolloutTurn>,
522 },
523 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 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
559fn is_recent(modified: SystemTime, now: SystemTime) -> bool {
562 now.duration_since(modified)
563 .map_or(true, |elapsed| elapsed <= RECENT_ACTIVITY_WINDOW)
564}
565
566fn 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
608fn 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 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 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
663fn 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
675fn 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
702fn 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 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
749fn 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 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 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
803pub 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 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(®istry);
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 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 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 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 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 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 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 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 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 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 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 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 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 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 *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 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 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 *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 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 *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 let later = now + HEARTBEAT_INTERVAL * 2;
1326 assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
1327 *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 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 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 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 assert_eq!(probe_thread_lock_in(None, ID), LockState::Unknown);
1391 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(®istry);
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(®istry);
1422 assert_eq!(registry.list()[0].state, SessionState::Ended);
1423 }
1424
1425 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 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 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 fn turn_at(path: &Path) -> Option<RolloutTurn> {
1470 read_last_turn(path).unwrap().map(|marker| marker.turn)
1471 }
1472
1473 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 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 assert_eq!(parse_turn_marker("ing()), 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 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 (
1545 vec![started(), complete(), complete()],
1546 Some(RolloutTurn::Closed),
1547 ),
1548 (
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 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 std::fs::write(&path, format!("{head_line}\n{}\n{}", started(), complete())).unwrap();
1580 assert_eq!(turn_at(&path), Some(RolloutTurn::Closed));
1581 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 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 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 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 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 let mut lines = vec![started()];
1637 lines.extend((0..4).map(|_| chatter(100 * 1024)));
1638 assert_eq!(turn_of(&lines), Some(RolloutTurn::Open));
1639 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 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(®istry, first), SessionState::Working);
1680
1681 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(®istry, done), SessionState::Idle);
1689
1690 append(&path, &started());
1692 let next = scan_at(&mut state, 3);
1693 assert_eq!(state_after(®istry, 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(®istry, interrupted), SessionState::Idle);
1698 }
1699
1700 #[test]
1701 fn a_closed_turn_on_first_sight_claims_nothing() {
1702 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(®istry, first), SessionState::Working);
1732
1733 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 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 assert_eq!(turns(&first), vec![None]);
1762 assert_eq!(track_of(&state, &path).turn, None);
1763 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 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 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 *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 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 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 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 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 *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 *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 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 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(®istry);
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 seen(None).apply(®istry);
2049 assert_eq!(registry.list()[0].state, SessionState::Working);
2050 seen(Some(RolloutTurn::Closed)).apply(®istry);
2051 assert_eq!(registry.list()[0].state, SessionState::Idle);
2052 }
2053}