supercode_harness/
codex_peer.rs1use std::collections::HashMap;
11use std::fs::File;
12use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
13use std::path::{Path, PathBuf};
14use std::time::{Duration, Instant};
15
16use serde_json::Value;
17
18const LIFECYCLE_SCAN_BYTES: u64 = 1024 * 1024;
19const LIFECYCLE_OVERLAP_BYTES: u64 = 64 * 1024;
20const OWNERSHIP_REFRESH_INTERVAL: Duration = Duration::from_secs(1);
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum CodexPeerStatus {
25 Running,
27 Idle,
29 Busy,
31}
32
33impl CodexPeerStatus {
34 pub const fn as_str(self) -> &'static str {
36 match self {
37 Self::Running => "running",
38 Self::Idle => "idle",
39 Self::Busy => "busy",
40 }
41 }
42}
43
44pub fn live_rollouts(sessions_root: &Path) -> HashMap<PathBuf, CodexPeerStatus> {
47 CodexPeerTracker::default().sample(sessions_root)
48}
49
50#[derive(Debug, Default)]
56pub(crate) struct CodexPeerTracker {
57 root: Option<PathBuf>,
58 open_rollouts: Vec<PathBuf>,
59 lifecycle: HashMap<PathBuf, CodexLifecycleCursor>,
60 refreshed_at: Option<Instant>,
61}
62
63#[derive(Debug, Default)]
64struct CodexLifecycleCursor {
65 offset: u64,
66 status: Option<CodexPeerStatus>,
67}
68
69impl CodexPeerTracker {
70 pub(crate) fn sample(&mut self, sessions_root: &Path) -> HashMap<PathBuf, CodexPeerStatus> {
71 let root = normalized_path(sessions_root);
72 let refresh = self.root.as_ref() != Some(&root)
73 || self
74 .refreshed_at
75 .is_none_or(|at| at.elapsed() >= OWNERSHIP_REFRESH_INTERVAL);
76 if refresh {
77 self.open_rollouts = platform_open_rollouts()
78 .into_iter()
79 .map(|path| normalized_path(&path))
80 .collect();
81 self.root = Some(root.clone());
82 self.refreshed_at = Some(Instant::now());
83 self.lifecycle
84 .retain(|path, _| self.open_rollouts.contains(path));
85 }
86 let mut statuses = HashMap::new();
87 for path in &self.open_rollouts {
88 if !(path.starts_with(&root)
89 && path.extension().and_then(|extension| extension.to_str()) == Some("jsonl"))
90 {
91 continue;
92 }
93 let cursor = self.lifecycle.entry(path.clone()).or_default();
94 let status = sample_lifecycle_status(path, cursor).unwrap_or(CodexPeerStatus::Running);
95 statuses.insert(path.clone(), status);
96 }
97 statuses
98 }
99}
100
101pub fn rollout_status(
103 live: &HashMap<PathBuf, CodexPeerStatus>,
104 path: &Path,
105) -> Option<CodexPeerStatus> {
106 live.get(&normalized_path(path)).copied()
107}
108
109pub(crate) fn rollout_lineage(path: &Path) -> Option<(String, Option<String>)> {
113 let mut header = String::new();
114 BufReader::new(File::open(path).ok()?.take(256 * 1024))
115 .read_line(&mut header)
116 .ok()?;
117 let value = serde_json::from_str::<Value>(&header).ok()?;
118 if value.get("type").and_then(Value::as_str) != Some("session_meta") {
119 return None;
120 }
121 let payload = value.get("payload")?;
122 let session_id = payload.get("id")?.as_str()?.to_string();
123 let parent_session_id = payload
124 .pointer("/source/subagent/thread_spawn/parent_thread_id")
125 .or_else(|| payload.get("parent_thread_id"))
126 .and_then(Value::as_str)
127 .map(str::to_string);
128 Some((session_id, parent_session_id))
129}
130
131fn sample_lifecycle_status(
132 path: &Path,
133 cursor: &mut CodexLifecycleCursor,
134) -> Option<CodexPeerStatus> {
135 let mut file = File::open(path).ok()?;
136 let length = file.metadata().ok()?.len();
137 if length < cursor.offset {
138 cursor.offset = 0;
139 cursor.status = None;
140 }
141 if cursor.offset == 0 {
142 cursor.status = latest_lifecycle_status_between(&mut file, 0, length);
143 } else if length > cursor.offset {
144 if let Some(status) = latest_lifecycle_status_between(&mut file, cursor.offset, length) {
145 cursor.status = Some(status);
146 }
147 }
148 cursor.offset = length;
149 cursor.status
150}
151
152fn latest_lifecycle_status_between(
153 file: &mut File,
154 floor: u64,
155 upper: u64,
156) -> Option<CodexPeerStatus> {
157 let mut end = upper;
158 while end > floor {
159 let start = end.saturating_sub(LIFECYCLE_SCAN_BYTES).max(floor);
160 file.seek(SeekFrom::Start(start)).ok()?;
161 let mut tail = vec![0; (end - start) as usize];
162 file.read_exact(&mut tail).ok()?;
163 if let Some(status) = lifecycle_status_in_tail(&tail, start == floor) {
164 return Some(status);
165 }
166 if start == floor {
167 break;
168 }
169 end = start.saturating_add(LIFECYCLE_OVERLAP_BYTES);
172 }
173 None
174}
175
176fn lifecycle_status_in_tail(
177 tail: &[u8],
178 starts_at_record_boundary: bool,
179) -> Option<CodexPeerStatus> {
180 const BOUNDARIES: [&str; 3] = [
181 "\"type\":\"task_started\"",
182 "\"type\":\"task_complete\"",
183 "\"type\":\"turn_aborted\"",
184 ];
185
186 let complete_start = if starts_at_record_boundary {
191 0
192 } else {
193 tail.iter()
194 .position(|byte| *byte == b'\n')
195 .map_or(tail.len(), |newline| newline + 1)
196 };
197 let text = std::str::from_utf8(&tail[complete_start..]).ok()?;
198 let mut search_end = text.len();
199 while let Some(candidate) = BOUNDARIES
200 .iter()
201 .filter_map(|boundary| text[..search_end].rfind(boundary))
202 .max()
203 {
204 let line_start = text[..candidate]
205 .rfind('\n')
206 .map_or(0, |newline| newline + 1);
207 let line_end = text[candidate..]
208 .find('\n')
209 .map_or(text.len(), |newline| candidate + newline);
210 let Ok(event) = serde_json::from_str::<Value>(&text[line_start..line_end]) else {
211 search_end = candidate;
212 continue;
213 };
214 if event.get("type").and_then(Value::as_str) != Some("event_msg") {
215 search_end = candidate;
216 continue;
217 }
218 match event
219 .get("payload")
220 .and_then(|payload| payload.get("type"))
221 .and_then(Value::as_str)
222 {
223 Some("task_started") => return Some(CodexPeerStatus::Busy),
224 Some("task_complete" | "turn_aborted") => return Some(CodexPeerStatus::Idle),
225 _ => {}
226 }
227 search_end = candidate;
228 }
229 None
230}
231
232fn normalized_path(path: &Path) -> PathBuf {
233 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
234}
235
236#[cfg(target_os = "macos")]
237fn platform_open_rollouts() -> Vec<PathBuf> {
238 use std::process::Command;
239
240 let Ok(processes) = Command::new("/usr/bin/pgrep")
245 .args(["-a", "-x", "codex"])
246 .output()
247 else {
248 return Vec::new();
249 };
250 let pids = String::from_utf8_lossy(&processes.stdout)
251 .lines()
252 .filter_map(|line| line.trim().parse::<u32>().ok())
253 .take(128)
254 .map(|pid| pid.to_string())
255 .collect::<Vec<_>>();
256 if pids.is_empty() {
257 return Vec::new();
258 }
259 let Ok(files) = Command::new("/usr/sbin/lsof")
260 .args(["-Fn", "-a", "-p", &pids.join(",")])
261 .output()
262 else {
263 return Vec::new();
264 };
265 String::from_utf8_lossy(&files.stdout)
266 .lines()
267 .filter_map(|line| line.strip_prefix('n'))
268 .filter(|path| path.ends_with(".jsonl"))
269 .map(PathBuf::from)
270 .collect()
271}
272
273#[cfg(target_os = "linux")]
274fn platform_open_rollouts() -> Vec<PathBuf> {
275 let Ok(processes) = std::fs::read_dir("/proc") else {
276 return Vec::new();
277 };
278 let mut paths = Vec::new();
279 for process in processes.flatten() {
280 let pid = process.file_name();
281 if !pid.as_encoded_bytes().iter().all(u8::is_ascii_digit) {
282 continue;
283 }
284 let process_root = process.path();
285 if std::fs::read_to_string(process_root.join("comm"))
286 .ok()
287 .is_none_or(|name| name.trim() != "codex")
288 {
289 continue;
290 }
291 let Ok(descriptors) = std::fs::read_dir(process_root.join("fd")) else {
292 continue;
293 };
294 paths.extend(
295 descriptors
296 .flatten()
297 .filter_map(|descriptor| std::fs::read_link(descriptor.path()).ok())
298 .filter(|path| {
299 path.extension().and_then(|extension| extension.to_str()) == Some("jsonl")
300 }),
301 );
302 }
303 paths
304}
305
306#[cfg(not(any(target_os = "macos", target_os = "linux")))]
307fn platform_open_rollouts() -> Vec<PathBuf> {
308 Vec::new()
309}
310
311#[cfg(test)]
312mod tests {
313 use std::fs::{remove_file, OpenOptions};
314 use std::io::Write;
315
316 use super::*;
317
318 #[test]
319 fn long_tool_heavy_turn_is_found_once_then_followed_incrementally() {
320 let path = std::env::temp_dir().join(format!(
321 "supercode-codex-long-turn-{}-{}.jsonl",
322 std::process::id(),
323 std::thread::current().name().unwrap_or("test")
324 ));
325 let mut file = File::create(&path).unwrap();
326 writeln!(
327 file,
328 r#"{{"type":"event_msg","payload":{{"type":"task_started"}}}}"#
329 )
330 .unwrap();
331 write!(
332 file,
333 r#"{{"type":"response_item","payload":"{}"}}"#,
334 "x".repeat(6 * 1024 * 1024)
335 )
336 .unwrap();
337 writeln!(file).unwrap();
338 file.flush().unwrap();
339
340 let mut cursor = CodexLifecycleCursor::default();
341 assert_eq!(
342 sample_lifecycle_status(&path, &mut cursor),
343 Some(CodexPeerStatus::Busy)
344 );
345 let first_offset = cursor.offset;
346
347 let mut file = OpenOptions::new().append(true).open(&path).unwrap();
348 writeln!(
349 file,
350 r#"{{"type":"event_msg","payload":{{"type":"item_completed"}}}}"#
351 )
352 .unwrap();
353 file.flush().unwrap();
354 assert_eq!(
355 sample_lifecycle_status(&path, &mut cursor),
356 Some(CodexPeerStatus::Busy)
357 );
358 assert!(cursor.offset > first_offset);
359
360 writeln!(
361 file,
362 r#"{{"type":"event_msg","payload":{{"type":"task_complete"}}}}"#
363 )
364 .unwrap();
365 file.flush().unwrap();
366 assert_eq!(
367 sample_lifecycle_status(&path, &mut cursor),
368 Some(CodexPeerStatus::Idle)
369 );
370 remove_file(path).unwrap();
371 }
372}