1use std::collections::{HashMap, HashSet};
46use std::io::{BufRead, BufReader, Read};
47use std::path::{Path, PathBuf};
48use std::sync::Arc;
49use std::time::{Duration, SystemTime};
50
51use serde::Deserialize;
52use tokio::task::JoinHandle;
53use tokio_util::sync::CancellationToken;
54
55use super::{Agent, ObserveRequest, SessionEvent, SessionsRegistry};
56
57const CODEX_HOME_ENV: &str = "CODEX_HOME";
59
60const WATCH_INTERVAL: Duration = Duration::from_secs(5);
62
63const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(300);
67
68const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60);
71
72const MAX_HEAD_BYTES: u64 = 1024 * 1024;
76
77fn codex_home() -> Option<PathBuf> {
79 if let Some(dir) = std::env::var_os(CODEX_HOME_ENV).filter(|d| !d.is_empty()) {
80 return Some(PathBuf::from(dir));
81 }
82 dirs::home_dir().map(|home| home.join(".codex"))
83}
84
85#[derive(Debug, Deserialize)]
88struct HeadLine {
89 #[serde(rename = "type")]
90 kind: String,
91 #[serde(default)]
92 payload: Option<SessionMeta>,
93}
94
95#[derive(Debug, Deserialize)]
97struct SessionMeta {
98 #[serde(default)]
101 id: Option<String>,
102 #[serde(default)]
103 session_id: Option<String>,
104 #[serde(default)]
105 cwd: Option<PathBuf>,
106 #[serde(default)]
109 source: Option<serde_json::Value>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114enum Head {
115 Session { id: String, cwd: Option<PathBuf> },
117 Skip,
119 Incomplete,
122}
123
124fn parse_head(line: &str) -> Head {
127 let Ok(head) = serde_json::from_str::<HeadLine>(line) else {
128 return Head::Skip;
129 };
130 let Some(meta) = head.payload.filter(|_| head.kind == "session_meta") else {
131 return Head::Skip;
132 };
133 if meta
134 .source
135 .as_ref()
136 .is_some_and(serde_json::Value::is_object)
137 {
138 return Head::Skip;
139 }
140 match meta
141 .id
142 .or(meta.session_id)
143 .filter(|id| !id.trim().is_empty())
144 {
145 Some(id) => Head::Session { id, cwd: meta.cwd },
146 None => Head::Skip,
147 }
148}
149
150fn read_head(path: &Path) -> Head {
152 let Ok(file) = std::fs::File::open(path) else {
153 return Head::Skip;
154 };
155 let mut line = String::new();
156 let mut reader = BufReader::new(file.take(MAX_HEAD_BYTES));
157 match reader.read_line(&mut line) {
158 Ok(_) if line.ends_with('\n') => parse_head(&line),
159 Ok(n) if n as u64 >= MAX_HEAD_BYTES => Head::Skip,
161 Ok(_) => Head::Incomplete,
162 Err(_) => Head::Skip,
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168enum LockState {
169 Held,
171 Free,
173 Absent,
175 Unknown,
177}
178
179#[cfg(unix)]
182fn probe_lock(locks: &Path, id: &str) -> LockState {
183 use nix::errno::Errno;
184 use nix::fcntl::{Flock, FlockArg};
185
186 let file = match std::fs::File::open(locks.join(format!("{id}.lock"))) {
187 Ok(file) => file,
188 Err(e) if e.kind() == std::io::ErrorKind::NotFound && locks.is_dir() => {
191 return LockState::Absent
192 }
193 Err(_) => return LockState::Unknown,
194 };
195 match Flock::lock(file, FlockArg::LockSharedNonblock) {
196 Ok(_guard) => LockState::Free,
198 Err((_, Errno::EWOULDBLOCK)) => LockState::Held,
199 Err(_) => LockState::Unknown,
200 }
201}
202
203#[cfg(not(unix))]
204fn probe_lock(_locks: &Path, _id: &str) -> LockState {
205 LockState::Unknown
206}
207
208#[derive(Debug, Clone)]
210enum Tracked {
211 Unread { size: u64 },
214 Skipped,
216 Session(SessionTrack),
218}
219
220#[derive(Debug, Clone)]
222struct SessionTrack {
223 id: String,
224 cwd: Option<PathBuf>,
225 size: u64,
226 lock_seen: bool,
230 ended: bool,
233 last_report: SystemTime,
235}
236
237type ScanState = HashMap<PathBuf, Tracked>;
239
240#[derive(Debug, Clone, PartialEq, Eq)]
242enum Action {
243 Seen {
245 id: String,
246 cwd: Option<PathBuf>,
247 transcript_path: PathBuf,
248 },
249 End { id: String },
251}
252
253impl Action {
254 fn apply(self, registry: &SessionsRegistry) {
255 match self {
256 Self::Seen {
257 id,
258 cwd,
259 transcript_path,
260 } => registry.observe(ObserveRequest {
261 pid: None,
262 agent: Agent::Codex,
263 session_id: id,
264 cwd,
265 transcript_path: Some(transcript_path),
266 event: SessionEvent::TranscriptDiscovered,
269 repo: None,
270 model: None,
271 }),
272 Self::End { id } => {
273 registry.end(&id, Some("codex rollout watcher"), None);
274 }
275 }
276 }
277}
278
279fn is_recent(modified: SystemTime, now: SystemTime) -> bool {
282 now.duration_since(modified)
283 .map_or(true, |elapsed| elapsed <= RECENT_ACTIVITY_WINDOW)
284}
285
286fn list_rollouts(root: &Path) -> Vec<(PathBuf, u64, SystemTime)> {
288 let mut out = Vec::new();
289 let subdirs = |dir: &Path| -> Vec<PathBuf> {
290 std::fs::read_dir(dir)
291 .map(|entries| {
292 entries
293 .flatten()
294 .filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
295 .map(|e| e.path())
296 .collect()
297 })
298 .unwrap_or_default()
299 };
300 for year in subdirs(root) {
301 for month in subdirs(&year) {
302 for day in subdirs(&month) {
303 let Ok(files) = std::fs::read_dir(&day) else {
304 continue;
305 };
306 for file in files.flatten() {
307 let path = file.path();
308 let is_rollout = path.extension().is_some_and(|e| e == "jsonl")
309 && path
310 .file_name()
311 .and_then(|n| n.to_str())
312 .is_some_and(|n| n.starts_with("rollout-"));
313 if !is_rollout {
314 continue;
315 }
316 let Ok(meta) = file.metadata() else {
317 continue;
318 };
319 let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
320 out.push((path, meta.len(), modified));
321 }
322 }
323 }
324 }
325 out
326}
327
328fn scan(
332 sessions: &Path,
333 locks: &Path,
334 state: &mut ScanState,
335 now: SystemTime,
336 probe: &dyn Fn(&Path, &str) -> LockState,
337) -> Vec<Action> {
338 let mut actions = Vec::new();
339 let mut present = HashSet::new();
340 for (path, size, modified) in list_rollouts(sessions) {
341 present.insert(path.clone());
342 let recent = is_recent(modified, now);
343 let tracked = match state.remove(&path) {
344 None if !recent => Tracked::Unread { size },
345 None => first_read(&path, size, locks, now, probe, &mut actions),
347 Some(Tracked::Unread { size: old }) if size != old && recent => {
348 first_read(&path, size, locks, now, probe, &mut actions)
349 }
350 Some(Tracked::Unread { .. }) => Tracked::Unread { size },
351 Some(Tracked::Skipped) => Tracked::Skipped,
352 Some(Tracked::Session(track)) => {
353 Tracked::Session(rescan(track, &path, size, locks, now, probe, &mut actions))
354 }
355 };
356 state.insert(path, tracked);
357 }
358 state.retain(|path, tracked| {
361 if present.contains(path) {
362 return true;
363 }
364 if let Tracked::Session(track) = tracked {
365 if !track.ended {
366 actions.push(Action::End {
367 id: track.id.clone(),
368 });
369 }
370 }
371 false
372 });
373 actions
374}
375
376fn first_read(
379 path: &Path,
380 size: u64,
381 locks: &Path,
382 now: SystemTime,
383 probe: &dyn Fn(&Path, &str) -> LockState,
384 actions: &mut Vec<Action>,
385) -> Tracked {
386 let (id, cwd) = match read_head(path) {
387 Head::Skip => return Tracked::Skipped,
388 Head::Incomplete => return Tracked::Unread { size },
389 Head::Session { id, cwd } => (id, cwd),
390 };
391 let lock = probe(locks, &id);
392 let gone = matches!(lock, LockState::Free | LockState::Absent);
395 if !gone {
396 actions.push(Action::Seen {
397 id: id.clone(),
398 cwd: cwd.clone(),
399 transcript_path: path.to_path_buf(),
400 });
401 }
402 Tracked::Session(SessionTrack {
403 id,
404 cwd,
405 size,
406 lock_seen: lock == LockState::Held,
407 ended: gone,
408 last_report: now,
409 })
410}
411
412fn rescan(
415 mut track: SessionTrack,
416 path: &Path,
417 size: u64,
418 locks: &Path,
419 now: SystemTime,
420 probe: &dyn Fn(&Path, &str) -> LockState,
421 actions: &mut Vec<Action>,
422) -> SessionTrack {
423 let grew = size > track.size;
424 track.size = size;
425 if track.ended {
426 if !grew {
427 return track;
428 }
429 track.ended = false;
432 track.lock_seen = false;
433 track.last_report = SystemTime::UNIX_EPOCH;
434 }
435 let lock = probe(locks, &track.id);
436 if lock == LockState::Held {
437 track.lock_seen = true;
438 }
439 if track.lock_seen && matches!(lock, LockState::Free | LockState::Absent) {
440 track.ended = true;
441 actions.push(Action::End {
442 id: track.id.clone(),
443 });
444 return track;
445 }
446 let alive = grew || lock == LockState::Held;
447 let due = now
448 .duration_since(track.last_report)
449 .is_ok_and(|since| since >= HEARTBEAT_INTERVAL);
450 if alive && due {
451 track.last_report = now;
452 actions.push(Action::Seen {
453 id: track.id.clone(),
454 cwd: track.cwd.clone(),
455 transcript_path: path.to_path_buf(),
456 });
457 }
458 track
459}
460
461pub fn spawn(registry: Arc<SessionsRegistry>, token: CancellationToken) -> JoinHandle<()> {
467 tokio::spawn(async move {
468 let Some(home) = codex_home() else {
469 tracing::debug!("no Codex home; Codex rollout watcher idle");
470 token.cancelled().await;
471 return;
472 };
473 let sessions = home.join("sessions");
474 let locks = home.join("thread-writer-locks");
475 tracing::debug!("Codex rollout watcher scanning {}", sessions.display());
476 let mut state = ScanState::new();
477 loop {
478 let (scan_sessions, scan_locks) = (sessions.clone(), locks.clone());
479 let mut owned = std::mem::take(&mut state);
481 let (returned, actions) = tokio::task::spawn_blocking(move || {
482 let actions = scan(
483 &scan_sessions,
484 &scan_locks,
485 &mut owned,
486 SystemTime::now(),
487 &probe_lock,
488 );
489 (owned, actions)
490 })
491 .await
492 .unwrap_or_else(|_| (ScanState::new(), Vec::new()));
493 state = returned;
494 for action in actions {
495 action.apply(®istry);
496 }
497 tokio::select! {
498 () = token.cancelled() => break,
499 () = tokio::time::sleep(WATCH_INTERVAL) => {}
500 }
501 }
502 })
503}
504
505#[cfg(test)]
506#[allow(clippy::unwrap_used, clippy::expect_used)]
507mod tests {
508 use super::*;
509 use std::cell::RefCell;
510 use std::io::Write;
511
512 use crate::sessions::SessionState;
513
514 const ID: &str = "019a0000-0000-7000-8000-00000000c0de";
515
516 fn head(id: &str, source: serde_json::Value) -> String {
517 serde_json::json!({
518 "timestamp": "2026-09-25T00:00:00Z",
519 "type": "session_meta",
520 "payload": {
521 "id": id,
522 "session_id": id,
523 "cwd": "/work/repo",
524 "originator": "codex-tui",
525 "source": source,
526 "base_instructions": { "text": "x".repeat(2048) },
527 },
528 })
529 .to_string()
530 }
531
532 fn write_rollout(root: &Path, id: &str, lines: &[String]) -> PathBuf {
534 let dir = root.join("2026").join("09").join("25");
535 std::fs::create_dir_all(&dir).unwrap();
536 let path = dir.join(format!("rollout-2026-09-25T00-00-00-{id}.jsonl"));
537 let mut f = std::fs::File::create(&path).unwrap();
538 for line in lines {
539 writeln!(f, "{line}").unwrap();
540 }
541 path
542 }
543
544 fn append(path: &Path, line: &str) {
545 let mut f = std::fs::OpenOptions::new().append(true).open(path).unwrap();
546 writeln!(f, "{line}").unwrap();
547 }
548
549 fn scripted(cell: &RefCell<LockState>) -> impl Fn(&Path, &str) -> LockState + '_ {
551 move |_, _| *cell.borrow()
552 }
553
554 fn ids(actions: &[Action]) -> Vec<String> {
555 actions
556 .iter()
557 .map(|a| match a {
558 Action::Seen { id, .. } => format!("seen:{id}"),
559 Action::End { id } => format!("end:{id}"),
560 })
561 .collect()
562 }
563
564 #[test]
565 fn parse_head_reads_a_session_and_skips_subagents_and_junk() {
566 assert_eq!(
567 parse_head(&head(ID, serde_json::json!("cli"))),
568 Head::Session {
569 id: ID.to_string(),
570 cwd: Some(PathBuf::from("/work/repo")),
571 }
572 );
573 let subagent = serde_json::json!({ "subagent": { "thread_spawn": {} } });
574 assert_eq!(parse_head(&head(ID, subagent)), Head::Skip);
575 assert_eq!(parse_head("not json"), Head::Skip);
576 assert_eq!(
577 parse_head(r#"{"type":"response_item","payload":{"id":"x"}}"#),
578 Head::Skip
579 );
580 assert_eq!(
581 parse_head(r#"{"type":"session_meta","payload":{"cwd":"/w"}}"#),
582 Head::Skip
583 );
584 assert_eq!(
586 parse_head(r#"{"type":"session_meta","payload":{"session_id":"s"}}"#),
587 Head::Session {
588 id: "s".to_string(),
589 cwd: None
590 }
591 );
592 }
593
594 #[test]
595 fn read_head_needs_a_complete_first_line() {
596 let tmp = tempfile::tempdir().unwrap();
597 let path = tmp.path().join("r.jsonl");
598 std::fs::write(&path, head(ID, serde_json::json!("vscode"))).unwrap();
599 assert_eq!(read_head(&path), Head::Incomplete);
601 std::fs::write(&path, "").unwrap();
602 assert_eq!(read_head(&path), Head::Incomplete);
603 std::fs::write(
604 &path,
605 format!("{}\n{{}}\n", head(ID, serde_json::json!("vscode"))),
606 )
607 .unwrap();
608 assert!(matches!(read_head(&path), Head::Session { .. }));
609 assert_eq!(read_head(&tmp.path().join("missing")), Head::Skip);
610 }
611
612 #[test]
613 fn a_recent_rollout_is_discovered_once_with_its_cwd() {
614 let tmp = tempfile::tempdir().unwrap();
615 let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
616 let lock = RefCell::new(LockState::Unknown);
617 let mut state = ScanState::new();
618 let now = SystemTime::now();
619 let first = scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock));
620 assert_eq!(
621 first,
622 vec![Action::Seen {
623 id: ID.to_string(),
624 cwd: Some(PathBuf::from("/work/repo")),
625 transcript_path: path,
626 }]
627 );
628 assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
630 }
631
632 #[test]
633 fn subagent_rollouts_and_non_rollout_files_are_never_reported() {
634 let tmp = tempfile::tempdir().unwrap();
635 let sub = serde_json::json!({ "subagent": "review" });
636 write_rollout(tmp.path(), "child", &[head("child", sub)]);
637 let dir = tmp.path().join("2026/09/25");
638 std::fs::write(dir.join("notes.jsonl"), "{}\n").unwrap();
639 std::fs::write(tmp.path().join("rollout-loose.jsonl"), "{}\n").unwrap();
640 let lock = RefCell::new(LockState::Held);
641 let mut state = ScanState::new();
642 let now = SystemTime::now();
643 assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
644 let later = now + HEARTBEAT_INTERVAL * 2;
645 assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
646 }
647
648 #[test]
649 fn an_old_rollout_is_recorded_silently_and_read_when_it_grows() {
650 let tmp = tempfile::tempdir().unwrap();
651 let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
652 let lock = RefCell::new(LockState::Unknown);
653 let mut state = ScanState::new();
654 let far = SystemTime::now() + RECENT_ACTIVITY_WINDOW * 10;
656 assert!(scan(tmp.path(), tmp.path(), &mut state, far, &scripted(&lock)).is_empty());
657 assert!(matches!(state[&path], Tracked::Unread { .. }));
658 append(&path, "{}");
660 let now = SystemTime::now();
661 assert_eq!(
662 ids(&scan(
663 tmp.path(),
664 tmp.path(),
665 &mut state,
666 now,
667 &scripted(&lock)
668 )),
669 vec![format!("seen:{ID}")]
670 );
671 }
672
673 #[test]
674 fn a_held_lock_heartbeats_and_its_release_ends_the_session() {
675 let tmp = tempfile::tempdir().unwrap();
676 write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
677 let lock = RefCell::new(LockState::Held);
678 let mut state = ScanState::new();
679 let t0 = SystemTime::now();
680 assert_eq!(
681 scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock)).len(),
682 1
683 );
684 let t1 = t0 + WATCH_INTERVAL;
686 assert!(scan(tmp.path(), tmp.path(), &mut state, t1, &scripted(&lock)).is_empty());
687 let t2 = t0 + HEARTBEAT_INTERVAL;
688 assert_eq!(
689 ids(&scan(
690 tmp.path(),
691 tmp.path(),
692 &mut state,
693 t2,
694 &scripted(&lock)
695 )),
696 vec![format!("seen:{ID}")]
697 );
698 *lock.borrow_mut() = LockState::Free;
700 assert_eq!(
701 ids(&scan(
702 tmp.path(),
703 tmp.path(),
704 &mut state,
705 t2,
706 &scripted(&lock)
707 )),
708 vec![format!("end:{ID}")]
709 );
710 let t3 = t2 + HEARTBEAT_INTERVAL;
712 assert!(scan(tmp.path(), tmp.path(), &mut state, t3, &scripted(&lock)).is_empty());
713 }
714
715 #[test]
716 fn a_lock_never_seen_held_does_not_end_the_session() {
717 let tmp = tempfile::tempdir().unwrap();
719 write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
720 let lock = RefCell::new(LockState::Unknown);
721 let mut state = ScanState::new();
722 let t0 = SystemTime::now();
723 scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
724 let later = t0 + HEARTBEAT_INTERVAL * 3;
725 assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
726 *lock.borrow_mut() = LockState::Held;
728 scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock));
729 *lock.borrow_mut() = LockState::Unknown;
730 let actions = scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock));
731 assert!(!ids(&actions).iter().any(|a| a.starts_with("end:")));
732 }
733
734 #[test]
735 fn growth_without_a_lock_is_a_heartbeat_not_a_state() {
736 let tmp = tempfile::tempdir().unwrap();
737 let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("exec"))]);
738 let lock = RefCell::new(LockState::Unknown);
739 let mut state = ScanState::new();
740 let t0 = SystemTime::now();
741 scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
742 append(&path, "{}");
743 let due = t0 + HEARTBEAT_INTERVAL;
744 assert_eq!(
745 ids(&scan(
746 tmp.path(),
747 tmp.path(),
748 &mut state,
749 due,
750 &scripted(&lock)
751 )),
752 vec![format!("seen:{ID}")]
753 );
754 }
755
756 #[test]
757 fn an_archived_rollout_ends_its_session_once() {
758 let tmp = tempfile::tempdir().unwrap();
759 let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("vscode"))]);
760 let lock = RefCell::new(LockState::Held);
761 let mut state = ScanState::new();
762 let now = SystemTime::now();
763 scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock));
764 let archived = tmp.path().join("archived.jsonl");
766 std::fs::rename(&path, archived).unwrap();
767 assert_eq!(
768 ids(&scan(
769 tmp.path(),
770 tmp.path(),
771 &mut state,
772 now,
773 &scripted(&lock)
774 )),
775 vec![format!("end:{ID}")]
776 );
777 assert!(state.is_empty());
778 assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
779 }
780
781 #[test]
782 fn a_resumed_session_is_reported_again_after_an_end() {
783 let tmp = tempfile::tempdir().unwrap();
784 let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
785 let lock = RefCell::new(LockState::Held);
786 let mut state = ScanState::new();
787 let t0 = SystemTime::now();
788 scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
789 *lock.borrow_mut() = LockState::Absent;
790 scan(tmp.path(), tmp.path(), &mut state, t0, &scripted(&lock));
791 *lock.borrow_mut() = LockState::Held;
793 append(&path, "{}");
794 let due = t0 + HEARTBEAT_INTERVAL;
795 assert_eq!(
796 ids(&scan(
797 tmp.path(),
798 tmp.path(),
799 &mut state,
800 due,
801 &scripted(&lock)
802 )),
803 vec![format!("seen:{ID}")]
804 );
805 }
806
807 #[test]
808 fn a_head_still_being_written_is_read_once_it_is_complete() {
809 let tmp = tempfile::tempdir().unwrap();
810 let dir = tmp.path().join("2026/09/25");
811 std::fs::create_dir_all(&dir).unwrap();
812 let path = dir.join(format!("rollout-2026-09-25T00-00-00-{ID}.jsonl"));
813 let full = head(ID, serde_json::json!("cli"));
814 std::fs::write(&path, &full[..full.len() / 2]).unwrap();
815 let lock = RefCell::new(LockState::Held);
816 let mut state = ScanState::new();
817 let now = SystemTime::now();
818 assert!(scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty());
819 assert!(matches!(state[&path], Tracked::Unread { .. }));
820 std::fs::write(&path, format!("{full}\n")).unwrap();
821 assert_eq!(
822 ids(&scan(
823 tmp.path(),
824 tmp.path(),
825 &mut state,
826 now,
827 &scripted(&lock)
828 )),
829 vec![format!("seen:{ID}")]
830 );
831 }
832
833 #[test]
834 fn a_restart_does_not_list_a_session_whose_lock_is_already_released() {
835 for gone in [LockState::Free, LockState::Absent] {
836 let tmp = tempfile::tempdir().unwrap();
837 let path = write_rollout(tmp.path(), ID, &[head(ID, serde_json::json!("cli"))]);
838 let lock = RefCell::new(gone);
839 let mut state = ScanState::new();
840 let now = SystemTime::now();
841 assert!(
842 scan(tmp.path(), tmp.path(), &mut state, now, &scripted(&lock)).is_empty(),
843 "{gone:?}"
844 );
845 let later = now + HEARTBEAT_INTERVAL * 2;
847 assert!(scan(tmp.path(), tmp.path(), &mut state, later, &scripted(&lock)).is_empty());
848 *lock.borrow_mut() = LockState::Held;
850 append(&path, "{}");
851 assert_eq!(
852 ids(&scan(
853 tmp.path(),
854 tmp.path(),
855 &mut state,
856 later,
857 &scripted(&lock)
858 )),
859 vec![format!("seen:{ID}")]
860 );
861 }
862 }
863
864 #[test]
865 fn scanning_a_missing_root_is_empty() {
866 let lock = RefCell::new(LockState::Unknown);
867 let mut state = ScanState::new();
868 let missing = Path::new("/nonexistent/codex/sessions");
869 assert!(scan(
870 missing,
871 missing,
872 &mut state,
873 SystemTime::now(),
874 &scripted(&lock)
875 )
876 .is_empty());
877 }
878
879 #[cfg(unix)]
880 #[test]
881 fn probe_lock_tells_held_from_free_and_absent() {
882 use nix::fcntl::{Flock, FlockArg};
883
884 let tmp = tempfile::tempdir().unwrap();
885 assert_eq!(
887 probe_lock(&tmp.path().join("missing"), ID),
888 LockState::Unknown
889 );
890 assert_eq!(probe_lock(tmp.path(), ID), LockState::Absent);
891 let path = tmp.path().join(format!("{ID}.lock"));
892 std::fs::write(&path, "").unwrap();
893 assert_eq!(probe_lock(tmp.path(), ID), LockState::Free);
894 let holder = Flock::lock(
896 std::fs::File::open(&path).unwrap(),
897 FlockArg::LockExclusiveNonblock,
898 )
899 .unwrap();
900 assert_eq!(probe_lock(tmp.path(), ID), LockState::Held);
901 assert_eq!(probe_lock(tmp.path(), ID), LockState::Held);
903 drop(holder);
904 assert_eq!(probe_lock(tmp.path(), ID), LockState::Free);
905 }
906
907 #[test]
908 fn actions_apply_to_the_registry_as_codex_sessions() {
909 let registry = SessionsRegistry::new();
910 Action::Seen {
911 id: ID.to_string(),
912 cwd: Some(PathBuf::from("/work/repo")),
913 transcript_path: PathBuf::from("/r.jsonl"),
914 }
915 .apply(®istry);
916 let listed = registry.list();
917 assert_eq!(listed.len(), 1);
918 assert_eq!(listed[0].agent, Agent::Codex);
919 assert_eq!(listed[0].state, SessionState::Idle);
920 assert_eq!(listed[0].cwd.as_deref(), Some(Path::new("/work/repo")));
921 Action::End { id: ID.to_string() }.apply(®istry);
922 assert_eq!(registry.list()[0].state, SessionState::Ended);
923 }
924}