1use std::collections::{HashMap, HashSet};
53use std::io::{BufRead, BufReader, Read};
54use std::path::{Path, PathBuf};
55use std::sync::Arc;
56use std::time::{Duration, SystemTime};
57
58use serde::Deserialize;
59use tokio::task::JoinHandle;
60use tokio_util::sync::CancellationToken;
61
62use super::{is_session_uuid, Agent, ObserveRequest, SessionEvent, SessionsRegistry};
63
64const UNREAD_PROBE_INTERVAL: Duration = Duration::from_secs(60);
70
71const CODEX_HOME_ENV: &str = "CODEX_HOME";
73
74const WATCH_INTERVAL: Duration = Duration::from_secs(5);
76
77const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(300);
81
82const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60);
85
86const MAX_HEAD_BYTES: u64 = 1024 * 1024;
90
91fn codex_home() -> Option<PathBuf> {
93 if let Some(dir) = std::env::var_os(CODEX_HOME_ENV).filter(|d| !d.is_empty()) {
94 return Some(PathBuf::from(dir));
95 }
96 dirs::home_dir().map(|home| home.join(".codex"))
97}
98
99#[derive(Debug, Deserialize)]
102struct HeadLine {
103 #[serde(rename = "type")]
104 kind: String,
105 #[serde(default)]
106 payload: Option<SessionMeta>,
107}
108
109#[derive(Debug, Deserialize)]
111struct SessionMeta {
112 #[serde(default)]
115 id: Option<String>,
116 #[serde(default)]
117 session_id: Option<String>,
118 #[serde(default)]
119 cwd: Option<PathBuf>,
120 #[serde(default)]
123 source: Option<serde_json::Value>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128enum Head {
129 Session { id: String, cwd: Option<PathBuf> },
131 Skip,
133 Incomplete,
136}
137
138fn parse_head(line: &str) -> Head {
141 let Ok(head) = serde_json::from_str::<HeadLine>(line) else {
142 return Head::Skip;
143 };
144 let Some(meta) = head.payload.filter(|_| head.kind == "session_meta") else {
145 return Head::Skip;
146 };
147 if meta
148 .source
149 .as_ref()
150 .is_some_and(serde_json::Value::is_object)
151 {
152 return Head::Skip;
153 }
154 match meta
155 .id
156 .or(meta.session_id)
157 .filter(|id| !id.trim().is_empty())
158 {
159 Some(id) => Head::Session { id, cwd: meta.cwd },
160 None => Head::Skip,
161 }
162}
163
164fn read_head(path: &Path) -> Head {
166 let Ok(file) = std::fs::File::open(path) else {
167 return Head::Skip;
168 };
169 let mut line = String::new();
170 let mut reader = BufReader::new(file.take(MAX_HEAD_BYTES));
171 match reader.read_line(&mut line) {
172 Ok(_) if line.ends_with('\n') => parse_head(&line),
173 Ok(n) if n as u64 >= MAX_HEAD_BYTES => Head::Skip,
175 Ok(_) => Head::Incomplete,
176 Err(_) => Head::Skip,
177 }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub(crate) enum LockState {
183 Held,
185 Free,
187 Absent,
189 Unknown,
191}
192
193#[cfg(unix)]
196fn probe_lock(locks: &Path, id: &str) -> LockState {
197 use nix::errno::Errno;
198 use nix::fcntl::{Flock, FlockArg};
199
200 let file = match std::fs::File::open(locks.join(format!("{id}.lock"))) {
201 Ok(file) => file,
202 Err(e) if e.kind() == std::io::ErrorKind::NotFound && locks.is_dir() => {
205 return LockState::Absent
206 }
207 Err(_) => return LockState::Unknown,
208 };
209 match Flock::lock(file, FlockArg::LockSharedNonblock) {
210 Ok(_guard) => LockState::Free,
212 Err((_, Errno::EWOULDBLOCK)) => LockState::Held,
213 Err(_) => LockState::Unknown,
214 }
215}
216
217#[cfg(not(unix))]
218fn probe_lock(_locks: &Path, _id: &str) -> LockState {
219 LockState::Unknown
220}
221
222pub(crate) fn probe_thread_lock(id: &str) -> LockState {
227 probe_thread_lock_in(codex_home().as_deref(), id)
228}
229
230fn probe_thread_lock_in(home: Option<&Path>, id: &str) -> LockState {
232 match home {
233 Some(home) => probe_lock(&home.join("thread-writer-locks"), id),
234 None => LockState::Unknown,
235 }
236}
237
238#[derive(Debug, Clone)]
240enum Tracked {
241 Unread { size: u64, probed: SystemTime },
245 Skipped,
247 Session(SessionTrack),
249}
250
251#[derive(Debug, Clone)]
253struct SessionTrack {
254 id: String,
255 cwd: Option<PathBuf>,
256 size: u64,
257 lock_seen: bool,
261 ended: bool,
264 last_report: SystemTime,
266}
267
268type ScanState = HashMap<PathBuf, Tracked>;
270
271#[derive(Debug, Clone, PartialEq, Eq)]
273enum Action {
274 Seen {
276 id: String,
277 cwd: Option<PathBuf>,
278 transcript_path: PathBuf,
279 },
280 End { id: String },
282}
283
284impl Action {
285 fn apply(self, registry: &SessionsRegistry) {
286 match self {
287 Self::Seen {
288 id,
289 cwd,
290 transcript_path,
291 } => registry.observe(ObserveRequest {
292 agent_id: None,
293 pid: None,
294 agent: Agent::Codex,
295 session_id: id,
296 cwd,
297 transcript_path: Some(transcript_path),
298 event: SessionEvent::TranscriptDiscovered,
301 repo: None,
302 model: None,
303 }),
304 Self::End { id } => {
305 registry.end(&id, Some("codex rollout watcher"), None);
306 }
307 }
308 }
309}
310
311fn is_recent(modified: SystemTime, now: SystemTime) -> bool {
314 now.duration_since(modified)
315 .map_or(true, |elapsed| elapsed <= RECENT_ACTIVITY_WINDOW)
316}
317
318fn list_rollouts(root: &Path) -> Vec<(PathBuf, u64, SystemTime)> {
320 let mut out = Vec::new();
321 let subdirs = |dir: &Path| -> Vec<PathBuf> {
322 std::fs::read_dir(dir)
323 .map(|entries| {
324 entries
325 .flatten()
326 .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
327 .map(|e| e.path())
328 .collect()
329 })
330 .unwrap_or_default()
331 };
332 for year in subdirs(root) {
333 for month in subdirs(&year) {
334 for day in subdirs(&month) {
335 let Ok(files) = std::fs::read_dir(&day) else {
336 continue;
337 };
338 for file in files.flatten() {
339 let path = file.path();
340 let is_rollout = path.extension().is_some_and(|e| e == "jsonl")
341 && path
342 .file_name()
343 .and_then(|n| n.to_str())
344 .is_some_and(|n| n.starts_with("rollout-"));
345 if !is_rollout {
346 continue;
347 }
348 let Ok(meta) = file.metadata() else {
349 continue;
350 };
351 let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
352 out.push((path, meta.len(), modified));
353 }
354 }
355 }
356 }
357 out
358}
359
360fn scan(
364 sessions: &Path,
365 locks: &Path,
366 state: &mut ScanState,
367 now: SystemTime,
368 probe: &dyn Fn(&Path, &str) -> LockState,
369) -> Vec<Action> {
370 let mut actions = Vec::new();
371 let mut present = HashSet::new();
372 for (path, size, modified) in list_rollouts(sessions) {
373 present.insert(path.clone());
374 let recent = is_recent(modified, now);
375 let tracked = match state.remove(&path) {
376 None if !recent => unread_or_held(&path, size, None, locks, now, probe, &mut actions),
377 None => first_read(&path, size, locks, now, probe, &mut actions),
379 Some(Tracked::Unread { size: old, .. }) if size != old && recent => {
380 first_read(&path, size, locks, now, probe, &mut actions)
381 }
382 Some(Tracked::Unread { probed, .. }) => {
383 unread_or_held(&path, size, Some(probed), locks, now, probe, &mut actions)
384 }
385 Some(Tracked::Skipped) => Tracked::Skipped,
386 Some(Tracked::Session(track)) => {
387 Tracked::Session(rescan(track, &path, size, locks, now, probe, &mut actions))
388 }
389 };
390 state.insert(path, tracked);
391 }
392 state.retain(|path, tracked| {
395 if present.contains(path) {
396 return true;
397 }
398 if let Tracked::Session(track) = tracked {
399 if !track.ended {
400 actions.push(Action::End {
401 id: track.id.clone(),
402 });
403 }
404 }
405 false
406 });
407 actions
408}
409
410fn thread_id_from_file_name(path: &Path) -> Option<&str> {
413 let stem = path.file_stem()?.to_str()?;
414 let split = stem.len().checked_sub(36)?;
415 let id = stem.get(split..)?;
416 let separated = split
417 .checked_sub(1)
418 .is_some_and(|dash| stem.as_bytes().get(dash) == Some(&b'-'));
419 (separated && is_session_uuid(id)).then_some(id)
420}
421
422fn unread_or_held(
428 path: &Path,
429 size: u64,
430 probed: Option<SystemTime>,
431 locks: &Path,
432 now: SystemTime,
433 probe: &dyn Fn(&Path, &str) -> LockState,
434 actions: &mut Vec<Action>,
435) -> Tracked {
436 if let Some(last) = probed {
437 if now.duration_since(last).unwrap_or_default() < UNREAD_PROBE_INTERVAL {
438 return Tracked::Unread { size, probed: last };
439 }
440 }
441 match thread_id_from_file_name(path) {
442 Some(id) if probe(locks, id) == LockState::Held => {
443 first_read(path, size, locks, now, probe, actions)
444 }
445 _ => Tracked::Unread { size, probed: now },
446 }
447}
448
449fn first_read(
452 path: &Path,
453 size: u64,
454 locks: &Path,
455 now: SystemTime,
456 probe: &dyn Fn(&Path, &str) -> LockState,
457 actions: &mut Vec<Action>,
458) -> Tracked {
459 let (id, cwd) = match read_head(path) {
460 Head::Skip => return Tracked::Skipped,
461 Head::Incomplete => return Tracked::Unread { size, probed: now },
462 Head::Session { id, cwd } => (id, cwd),
463 };
464 let lock = probe(locks, &id);
465 let gone = matches!(lock, LockState::Free | LockState::Absent);
468 if !gone {
469 actions.push(Action::Seen {
470 id: id.clone(),
471 cwd: cwd.clone(),
472 transcript_path: path.to_path_buf(),
473 });
474 }
475 Tracked::Session(SessionTrack {
476 id,
477 cwd,
478 size,
479 lock_seen: lock == LockState::Held,
480 ended: gone,
481 last_report: now,
482 })
483}
484
485fn rescan(
488 mut track: SessionTrack,
489 path: &Path,
490 size: u64,
491 locks: &Path,
492 now: SystemTime,
493 probe: &dyn Fn(&Path, &str) -> LockState,
494 actions: &mut Vec<Action>,
495) -> SessionTrack {
496 let grew = size > track.size;
497 track.size = size;
498 if track.ended {
499 if !grew {
500 return track;
501 }
502 track.ended = false;
505 track.lock_seen = false;
506 track.last_report = SystemTime::UNIX_EPOCH;
507 }
508 let lock = probe(locks, &track.id);
509 if lock == LockState::Held {
510 track.lock_seen = true;
511 }
512 if track.lock_seen && matches!(lock, LockState::Free | LockState::Absent) {
513 track.ended = true;
514 actions.push(Action::End {
515 id: track.id.clone(),
516 });
517 return track;
518 }
519 let alive = grew || lock == LockState::Held;
520 let due = now
521 .duration_since(track.last_report)
522 .is_ok_and(|since| since >= HEARTBEAT_INTERVAL);
523 if alive && due {
524 track.last_report = now;
525 actions.push(Action::Seen {
526 id: track.id.clone(),
527 cwd: track.cwd.clone(),
528 transcript_path: path.to_path_buf(),
529 });
530 }
531 track
532}
533
534pub fn spawn(registry: Arc<SessionsRegistry>, token: CancellationToken) -> JoinHandle<()> {
540 tokio::spawn(async move {
541 let Some(home) = codex_home() else {
542 tracing::debug!("no Codex home; Codex rollout watcher idle");
543 token.cancelled().await;
544 return;
545 };
546 let sessions = home.join("sessions");
547 let locks = home.join("thread-writer-locks");
548 tracing::debug!("Codex rollout watcher scanning {}", sessions.display());
549 let mut state = ScanState::new();
550 loop {
551 let (scan_sessions, scan_locks) = (sessions.clone(), locks.clone());
552 let mut owned = std::mem::take(&mut state);
554 let (returned, actions) = tokio::task::spawn_blocking(move || {
555 let actions = scan(
556 &scan_sessions,
557 &scan_locks,
558 &mut owned,
559 SystemTime::now(),
560 &probe_lock,
561 );
562 (owned, actions)
563 })
564 .await
565 .unwrap_or_else(|_| (ScanState::new(), Vec::new()));
566 state = returned;
567 for action in actions {
568 action.apply(®istry);
569 }
570 tokio::select! {
571 () = token.cancelled() => break,
572 () = tokio::time::sleep(WATCH_INTERVAL) => {}
573 }
574 }
575 })
576}
577
578#[cfg(test)]
579#[allow(clippy::unwrap_used, clippy::expect_used)]
580mod tests {
581 use super::*;
582 use std::cell::RefCell;
583 use std::io::Write;
584
585 use crate::sessions::SessionState;
586
587 const ID: &str = "019a0000-0000-7000-8000-00000000c0de";
588
589 fn head(id: &str, source: serde_json::Value) -> String {
590 serde_json::json!({
591 "timestamp": "2026-09-25T00:00:00Z",
592 "type": "session_meta",
593 "payload": {
594 "id": id,
595 "session_id": id,
596 "cwd": "/work/repo",
597 "originator": "codex-tui",
598 "source": source,
599 "base_instructions": { "text": "x".repeat(2048) },
600 },
601 })
602 .to_string()
603 }
604
605 fn write_rollout(root: &Path, id: &str, lines: &[String]) -> PathBuf {
607 let dir = root.join("2026").join("09").join("25");
608 std::fs::create_dir_all(&dir).unwrap();
609 let path = dir.join(format!("rollout-2026-09-25T00-00-00-{id}.jsonl"));
610 let mut f = std::fs::File::create(&path).unwrap();
611 for line in lines {
612 writeln!(f, "{line}").unwrap();
613 }
614 path
615 }
616
617 fn append(path: &Path, line: &str) {
618 let mut f = std::fs::OpenOptions::new().append(true).open(path).unwrap();
619 writeln!(f, "{line}").unwrap();
620 }
621
622 fn scripted(cell: &RefCell<LockState>) -> impl Fn(&Path, &str) -> LockState + '_ {
624 move |_, _| *cell.borrow()
625 }
626
627 fn ids(actions: &[Action]) -> Vec<String> {
628 actions
629 .iter()
630 .map(|a| match a {
631 Action::Seen { id, .. } => format!("seen:{id}"),
632 Action::End { id } => format!("end:{id}"),
633 })
634 .collect()
635 }
636
637 #[test]
638 fn parse_head_reads_a_session_and_skips_subagents_and_junk() {
639 assert_eq!(
640 parse_head(&head(ID, serde_json::json!("cli"))),
641 Head::Session {
642 id: ID.to_string(),
643 cwd: Some(PathBuf::from("/work/repo")),
644 }
645 );
646 let subagent = serde_json::json!({ "subagent": { "thread_spawn": {} } });
647 assert_eq!(parse_head(&head(ID, subagent)), Head::Skip);
648 assert_eq!(parse_head("not json"), Head::Skip);
649 assert_eq!(
650 parse_head(r#"{"type":"response_item","payload":{"id":"x"}}"#),
651 Head::Skip
652 );
653 assert_eq!(
654 parse_head(r#"{"type":"session_meta","payload":{"cwd":"/w"}}"#),
655 Head::Skip
656 );
657 assert_eq!(
659 parse_head(r#"{"type":"session_meta","payload":{"session_id":"s"}}"#),
660 Head::Session {
661 id: "s".to_string(),
662 cwd: None
663 }
664 );
665 }
666
667 #[test]
668 fn read_head_needs_a_complete_first_line() {
669 let tmp = tempfile::tempdir().unwrap();
670 let path = tmp.path().join("r.jsonl");
671 std::fs::write(&path, head(ID, serde_json::json!("vscode"))).unwrap();
672 assert_eq!(read_head(&path), Head::Incomplete);
674 std::fs::write(&path, "").unwrap();
675 assert_eq!(read_head(&path), Head::Incomplete);
676 std::fs::write(
677 &path,
678 format!("{}\n{{}}\n", head(ID, serde_json::json!("vscode"))),
679 )
680 .unwrap();
681 assert!(matches!(read_head(&path), Head::Session { .. }));
682 assert_eq!(read_head(&tmp.path().join("missing")), Head::Skip);
683 }
684
685 #[test]
686 fn a_recent_rollout_is_discovered_once_with_its_cwd() {
687 let tmp = tempfile::tempdir().unwrap();
688 let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
689 let lock = RefCell::new(LockState::Unknown);
690 let mut state = ScanState::new();
691 let now = SystemTime::now();
692 let first = scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock));
693 assert_eq!(
694 first,
695 vec![Action::Seen {
696 id: ID.to_string(),
697 cwd: Some(PathBuf::from("/work/repo")),
698 transcript_path: path,
699 }]
700 );
701 assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
703 }
704
705 #[test]
706 fn subagent_rollouts_and_non_rollout_files_are_never_reported() {
707 let tmp = tempfile::tempdir().unwrap();
708 let sub = serde_json::json!({ "subagent": "review" });
709 write_rollout(tmp.path(), "child", &[head("child", sub)]);
710 let dir = tmp.path().join("2026/09/25");
711 std::fs::write(dir.join("notes.jsonl"), "{}\n").unwrap();
712 std::fs::write(tmp.path().join("rollout-loose.jsonl"), "{}\n").unwrap();
713 let lock = RefCell::new(LockState::Held);
714 let mut state = ScanState::new();
715 let now = SystemTime::now();
716 assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
717 let later = now + HEARTBEAT_INTERVAL * 2;
718 assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
719 }
720
721 #[test]
722 fn an_old_rollout_is_recorded_silently_and_read_when_it_grows() {
723 let tmp = tempfile::tempdir().unwrap();
724 let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
725 let lock = RefCell::new(LockState::Unknown);
726 let mut state = ScanState::new();
727 let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
729 assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
730 assert!(matches!(state[&path], Tracked::Unread { .. }));
731 append(&path, "{}");
733 let now = SystemTime::now();
734 assert_eq!(
735 ids(&scan(
736 tmp.path(),
737 tmp.path(),
738 &mut state,
739 now,
740 &scripted(&lock)
741 )),
742 vec![format!("seen:{ID}")]
743 );
744 }
745
746 #[test]
747 fn an_old_rollout_whose_lock_is_held_is_announced_regardless_of_its_age() {
748 let tmp = tempfile::tempdir().unwrap();
751 let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
752 let lock = RefCell::new(LockState::Held);
753 let mut state = ScanState::new();
754 let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
755 assert_eq!(
756 scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)),
757 vec![Action::Seen {
758 id: ID.to_string(),
759 cwd: Some(PathBuf::from("/work/repo")),
760 transcript_path: path.clone(),
761 }]
762 );
763 assert!(matches!(state[&path], Tracked::Session(_)));
764 assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
766 *lock.borrow_mut() = LockState::Free;
767 assert_eq!(
768 ids(&scan(
769 tmp.path(),
770 tmp.path(),
771 &mut state,
772 far,
773 &scripted(&lock)
774 )),
775 vec![format!("end:{ID}")]
776 );
777 }
778
779 #[test]
780 fn an_old_rollout_is_announced_when_its_lock_becomes_held_later() {
781 let tmp = tempfile::tempdir().unwrap();
784 let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
785 let lock = RefCell::new(LockState::Absent);
786 let mut state = ScanState::new();
787 let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
788 assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
789 assert!(matches!(state[&path], Tracked::Unread { .. }));
790 *lock.borrow_mut() = LockState::Held;
791 assert_eq!(
792 ids(&scan(
793 tmp.path(),
794 tmp.path(),
795 &mut state,
796 far + UNREAD_PROBE_INTERVAL,
797 &scripted(&lock)
798 )),
799 vec![format!("seen:{ID}")]
800 );
801 }
802
803 #[test]
804 fn unread_rollouts_are_probed_once_per_interval_not_every_scan() {
805 let tmp = tempfile::tempdir().unwrap();
808 write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
809 let probes = std::cell::Cell::new(0_u32);
810 let counting = |_: &Path, _: &str| {
811 probes.set(probes.get() + 1);
812 LockState::Absent
813 };
814 let mut state = ScanState::new();
815 let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
816 scan(tmp.path(), tmp.path(), &mut state, far, &counting);
817 assert_eq!(probes.get(), 1, "probed on first sight");
818 for scans in 1..=10 {
819 scan(
820 tmp.path(),
821 tmp.path(),
822 &mut state,
823 far + WATCH_INTERVAL * scans,
824 &counting,
825 );
826 }
827 assert_eq!(probes.get(), 1, "not again within the interval");
828 scan(
829 tmp.path(),
830 tmp.path(),
831 &mut state,
832 far + UNREAD_PROBE_INTERVAL,
833 &counting,
834 );
835 assert_eq!(probes.get(), 2, "once the interval has passed");
836 }
837
838 #[test]
839 fn an_old_rollout_with_a_free_or_unknown_lock_stays_unread() {
840 let tmp = tempfile::tempdir().unwrap();
841 let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
842 let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
843 for held in [LockState::Free, LockState::Absent, LockState::Unknown] {
844 let lock = RefCell::new(held);
845 let mut state = ScanState::new();
846 assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
847 assert!(matches!(state[&path], Tracked::Unread { .. }), "{held:?}");
848 }
849 }
850
851 #[test]
852 fn an_old_subagent_rollout_with_a_held_lock_is_skipped_not_announced() {
853 let tmp = tempfile::tempdir().unwrap();
854 let sub = serde_json::json!({ "subagent": "review" });
855 let path = write_rollout(tmp.path(), ID, &[head(ID, sub)]);
856 let lock = RefCell::new(LockState::Held);
857 let mut state = ScanState::new();
858 let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
859 assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
860 assert!(matches!(state[&path], Tracked::Skipped));
861 }
862
863 #[test]
864 fn thread_ids_come_only_from_a_uuid_ending_the_file_name() {
865 let named = |name: &str| thread_id_from_file_name(Path::new(name)).map(str::to_string);
866 assert_eq!(
867 named(&format!("rollout-2026-09-25T00-00-00-{ID}.jsonl")),
868 Some(ID.to_string())
869 );
870 assert_eq!(named("rollout-2026-09-25T00-00-00-child.jsonl"), None);
872 assert_eq!(named("x.jsonl"), None);
873 assert_eq!(
874 named("rollout-019a0000-0000-7000-8000-00000000zzzz.jsonl"),
875 None
876 );
877 assert_eq!(named(&format!("rollout{ID}.jsonl")), None);
878 assert_eq!(named(&format!("{ID}.jsonl")), None);
879 }
880
881 #[test]
882 fn a_held_lock_heartbeats_and_its_release_ends_the_session() {
883 let tmp = tempfile::tempdir().unwrap();
884 write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
885 let lock = RefCell::new(LockState::Held);
886 let mut state = ScanState::new();
887 let t0 = SystemTime::now();
888 assert_eq!(
889 scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock)).len(),
890 1
891 );
892 let t1 = t0 + WATCH_INTERVAL;
894 assert!(scan(tmp.path(), tmp.path(), &mut state, t1, &scripted(&lock)).is_empty());
895 let t2 = t0 + HEARTBEAT_INTERVAL;
896 assert_eq!(
897 ids(&scan(
898 tmp.path(),
899 tmp.path(),
900 &mut state,
901 t2,
902 &scripted(&lock)
903 )),
904 vec![format!("seen:{ID}")]
905 );
906 *lock.borrow_mut() = LockState::Free;
908 assert_eq!(
909 ids(&scan(
910 tmp.path(),
911 tmp.path(),
912 &mut state,
913 t2,
914 &scripted(&lock)
915 )),
916 vec![format!("end:{ID}")]
917 );
918 let t3 = t2 + HEARTBEAT_INTERVAL;
920 assert!(scan(tmp.path(), tmp.path(), &mut state, t3, &scripted(&lock)).is_empty());
921 }
922
923 #[test]
924 fn a_lock_never_seen_held_does_not_end_the_session() {
925 let tmp = tempfile::tempdir().unwrap();
927 write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
928 let lock = RefCell::new(LockState::Unknown);
929 let mut state = ScanState::new();
930 let t0 = SystemTime::now();
931 scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
932 let later = t0 + HEARTBEAT_INTERVAL * 3;
933 assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
934 *lock.borrow_mut() = LockState::Held;
936 scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock));
937 *lock.borrow_mut() = LockState::Unknown;
938 let actions = scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock));
939 assert!(!ids(&actions).iter().any(|a| a.starts_with("end:")));
940 }
941
942 #[test]
943 fn growth_without_a_lock_is_a_heartbeat_not_a_state() {
944 let tmp = tempfile::tempdir().unwrap();
945 let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("exec"))]);
946 let lock = RefCell::new(LockState::Unknown);
947 let mut state = ScanState::new();
948 let t0 = SystemTime::now();
949 scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
950 append(&path, "{}");
951 let due = t0 + HEARTBEAT_INTERVAL;
952 assert_eq!(
953 ids(&scan(
954 tmp.path(),
955 tmp.path(),
956 &mut state,
957 due,
958 &scripted(&lock)
959 )),
960 vec![format!("seen:{ID}")]
961 );
962 }
963
964 #[test]
965 fn an_archived_rollout_ends_its_session_once() {
966 let tmp = tempfile::tempdir().unwrap();
967 let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("vscode"))]);
968 let lock = RefCell::new(LockState::Held);
969 let mut state = ScanState::new();
970 let now = SystemTime::now();
971 scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock));
972 let archived = tmp.path().join("archived.jsonl");
974 std::fs::rename(&path, archived).unwrap();
975 assert_eq!(
976 ids(&scan(
977 tmp.path(),
978 tmp.path(),
979 &mut state,
980 now,
981 &scripted(&lock)
982 )),
983 vec![format!("end:{ID}")]
984 );
985 assert!(state.is_empty());
986 assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
987 }
988
989 #[test]
990 fn a_resumed_session_is_reported_again_after_an_end() {
991 let tmp = tempfile::tempdir().unwrap();
992 let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
993 let lock = RefCell::new(LockState::Held);
994 let mut state = ScanState::new();
995 let t0 = SystemTime::now();
996 scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
997 *lock.borrow_mut() = LockState::Absent;
998 scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
999 *lock.borrow_mut() = LockState::Held;
1001 append(&path, "{}");
1002 let due = t0 + HEARTBEAT_INTERVAL;
1003 assert_eq!(
1004 ids(&scan(
1005 tmp.path(),
1006 tmp.path(),
1007 &mut state,
1008 due,
1009 &scripted(&lock)
1010 )),
1011 vec![format!("seen:{ID}")]
1012 );
1013 }
1014
1015 #[test]
1016 fn a_head_still_being_written_is_read_once_it_is_complete() {
1017 let tmp = tempfile::tempdir().unwrap();
1018 let dir = tmp.path().join("2026/09/25");
1019 std::fs::create_dir_all(&dir).unwrap();
1020 let path = dir.join(format!("rollout-2026-09-25T00-00-00-{ID}.jsonl"));
1021 let full = head(ID, serde_json::json!("cli"));
1022 std::fs::write(&path, &full[..full.len() / 2]).unwrap();
1023 let lock = RefCell::new(LockState::Held);
1024 let mut state = ScanState::new();
1025 let now = SystemTime::now();
1026 assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
1027 assert!(matches!(state[&path], Tracked::Unread { .. }));
1028 std::fs::write(&path, format!("{full}\n")).unwrap();
1029 assert_eq!(
1030 ids(&scan(
1031 tmp.path(),
1032 tmp.path(),
1033 &mut state,
1034 now,
1035 &scripted(&lock)
1036 )),
1037 vec![format!("seen:{ID}")]
1038 );
1039 }
1040
1041 #[test]
1042 fn a_restart_does_not_list_a_session_whose_lock_is_already_released() {
1043 for gone in [LockState::Free, LockState::Absent] {
1044 let tmp = tempfile::tempdir().unwrap();
1045 let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
1046 let lock = RefCell::new(gone);
1047 let mut state = ScanState::new();
1048 let now = SystemTime::now();
1049 assert!(
1050 scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty(),
1051 "{gone:?}"
1052 );
1053 let later = now + HEARTBEAT_INTERVAL * 2;
1055 assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
1056 *lock.borrow_mut() = LockState::Held;
1058 append(&path, "{}");
1059 assert_eq!(
1060 ids(&scan(
1061 tmp.path(),
1062 tmp.path(),
1063 &mut state,
1064 later,
1065 &scripted(&lock)
1066 )),
1067 vec![format!("seen:{ID}")]
1068 );
1069 }
1070 }
1071
1072 #[test]
1073 fn scanning_a_missing_root_is_empty() {
1074 let lock = RefCell::new(LockState::Unknown);
1075 let mut state = ScanState::new();
1076 let missing = Path::new("/nonexistent/codex/sessions");
1077 assert!(scan(
1078 missing,
1079 missing,
1080 &mut state,
1081 SystemTime::now(),
1082 &scripted(&lock)
1083 )
1084 .is_empty());
1085 }
1086
1087 #[cfg(unix)]
1088 #[test]
1089 fn probe_lock_tells_held_from_free_and_absent() {
1090 use nix::fcntl::{Flock, FlockArg};
1091
1092 let tmp = tempfile::tempdir().unwrap();
1093 assert_eq!(
1095 probe_lock(&tmp.path().join("missing"), ID),
1096 LockState::Unknown
1097 );
1098 assert_eq!(probe_lock(tmp.path(), ID), LockState::Absent);
1099 let path = tmp.path().join(format!("{ID}.lock"));
1100 std::fs::write(&path, "").unwrap();
1101 assert_eq!(probe_lock(tmp.path(), ID), LockState::Free);
1102 let holder = Flock::lock(
1104 std::fs::File::open(&path).unwrap(),
1105 FlockArg::LockExclusiveNonblock,
1106 )
1107 .unwrap();
1108 assert_eq!(probe_lock(tmp.path(), ID), LockState::Held);
1109 assert_eq!(probe_lock(tmp.path(), ID), LockState::Held);
1111 drop(holder);
1112 assert_eq!(probe_lock(tmp.path(), ID), LockState::Free);
1113 }
1114
1115 #[test]
1116 fn the_thread_lock_probe_reads_the_resolved_homes_lock_directory() {
1117 let tmp = tempfile::tempdir().unwrap();
1118 assert_eq!(probe_thread_lock_in(None, ID), LockState::Unknown);
1120 assert_eq!(
1122 probe_thread_lock_in(Some(tmp.path()), ID),
1123 LockState::Unknown
1124 );
1125 let locks = tmp.path().join("thread-writer-locks");
1126 std::fs::create_dir(&locks).unwrap();
1127 assert_eq!(
1128 probe_thread_lock_in(Some(tmp.path()), ID),
1129 LockState::Absent
1130 );
1131 std::fs::write(locks.join(format!("{ID}.lock")), "").unwrap();
1132 assert_eq!(probe_thread_lock_in(Some(tmp.path()), ID), LockState::Free);
1133 }
1134
1135 #[test]
1136 fn actions_apply_to_the_registry_as_codex_sessions() {
1137 let registry = SessionsRegistry::new();
1138 Action::Seen {
1139 id: ID.to_string(),
1140 cwd: Some(PathBuf::from("/work/repo")),
1141 transcript_path: PathBuf::from("/r.jsonl"),
1142 }
1143 .apply(®istry);
1144 let listed = registry.list();
1145 assert_eq!(listed.len(), 1);
1146 assert_eq!(listed[0].agent, Agent::Codex);
1147 assert_eq!(listed[0].state, SessionState::Idle);
1148 assert_eq!(listed[0].cwd.as_deref(), Some(Path::new("/work/repo")));
1149 Action::End { id: ID.to_string() }.apply(®istry);
1150 assert_eq!(registry.list()[0].state, SessionState::Ended);
1151 }
1152}