From 424e76082ecafcdb49aa5218e577847eea546009 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Wed, 12 Aug 2026 16:02:16 -0700 Subject: [PATCH 1/4] feat(harness): read grok's still-running line as live preview --- src/harness/summary.rs | 41 ++++++++- src/harness/summary_tests.rs | 83 +++++++++++++++++++ tests/corpus/README.md | 2 + tests/corpus/preview_grok_still_running.bin | 39 +++++++++ .../preview_grok_subagent_scrollback.bin | 39 +++++++++ 5 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 tests/corpus/preview_grok_still_running.bin create mode 100644 tests/corpus/preview_grok_subagent_scrollback.bin diff --git a/src/harness/summary.rs b/src/harness/summary.rs index 04be348..6e0dd2f 100644 --- a/src/harness/summary.rs +++ b/src/harness/summary.rs @@ -419,8 +419,9 @@ fn codex_working(after_paren: &str) -> String { // ------------------------------------------------------------------ grok -- /// grok (alt screen). The pin is its bordered input box; the status row -/// (braille spinner while working, `Worked for {n}s` after a turn) is the -/// first painted row above the box's top border. +/// (braille spinner while working, `Worked for {n}s` after a turn, or +/// `◎ … still running` / `◎ waiting` while background work is live) is +/// the first painted row above the box's top border. pub struct GrokSummary; impl SummaryAdapter for GrokSummary { @@ -436,7 +437,11 @@ impl SummaryAdapter for GrokSummary { if let Some(text) = spinner_text(t, |c| ('\u{2800}'..='\u{28FF}').contains(&c)) { return Some((text, "grok:spinner")); } - grok_worked(t).then(|| (t.to_string(), "grok:worked")) + // Still-running is the same probe, never a scan: a closer spinner + // or Worked-for row already returned above. + grok_worked(t) + .then(|| (t.to_string(), "grok:worked")) + .or_else(|| grok_still_running(t).map(|text| (text, "grok:still-running"))) } fn model_label(&self, rows: &[String]) -> Option { @@ -476,6 +481,36 @@ fn grok_worked(t: &str) -> bool { }) } +/// Background-task chrome grok paints above the box while the main turn +/// looks idle. The `◎` head and the ` · send a message to interrupt` hint +/// drop; `waiting` and `{count} still running` stay. Scrollback such as +/// `Subagent running:` has no `◎` and never matches. +fn grok_still_running(t: &str) -> Option { + let rest = t.strip_prefix("◎ ")?; + let rest = rest + .strip_suffix(" · send a message to interrupt") + .unwrap_or(rest); + if rest == "waiting" { + return Some(rest.to_string()); + } + let body = rest.strip_suffix(" still running")?; + body.split(" · ") + .all(grok_still_running_count) + .then(|| rest.to_string()) +} + +/// One count segment: ascii digits, a space, then one to three words. +fn grok_still_running_count(seg: &str) -> bool { + let digits = seg.chars().take_while(char::is_ascii_digit).count(); + if digits == 0 { + return false; + } + let Some(words) = seg[digits..].strip_prefix(' ') else { + return false; + }; + (1..=3).contains(&words.split_whitespace().count()) +} + /// `╰──── Grok 4.5 (xhigh) · always-approve ─╯` → `Grok 4.5 (xhigh)`: the /// text grok embeds in its bottom border, first ` · ` segment (the second is /// the approval mode). A plain border has nothing after its last `─` and diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index 153f4be..61a52dd 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -681,6 +681,73 @@ fn grok_status_shapes() { assert_eq!(GrokSummary.model_label(&plain), None); } +/// Still-running chrome: `◎` plus a count phrase or `waiting`. The +/// interrupt hint drops; body-shaped lookalikes and pre-0.2.109 wording +/// do not match. A closer Worked-for row wins: no upward scan. +#[test] +fn grok_still_running_shapes() { + let boxed = [ + " ╭──────────────────────╮", + " │ ❯ │", + " ╰── Grok 4.5 (xhigh) · always-approve ─╯", + ]; + let probe = |status: &str| { + let mut rows = vec![status, ""]; + rows.extend(boxed); + GrokSummary.live_preview(&rs(&rows)) + }; + assert_eq!( + probe(" ◎ 1 subagent still running"), + Some(("1 subagent still running".to_string(), "grok:still-running")) + ); + assert_eq!( + probe(" ◎ 1 command · 2 monitors · 1 loop · 1 subagent still running"), + Some(( + "1 command · 2 monitors · 1 loop · 1 subagent still running".to_string(), + "grok:still-running" + )) + ); + assert_eq!( + probe(" ◎ 1 command still running · send a message to interrupt"), + Some(("1 command still running".to_string(), "grok:still-running")) + ); + assert_eq!( + probe(" ◎ waiting · send a message to interrupt"), + Some(("waiting".to_string(), "grok:still-running")) + ); + assert_eq!( + probe(" ◎ waiting"), + Some(("waiting".to_string(), "grok:still-running")) + ); + + for row in [ + " 1 subagent still running", + " watching · 1 subagent", + " Subagent running: \"do the thing\"", + " ◎ still running", + " ◎ 1 to finish", + " ◎ 1 command still running · leftover", + " ◎ 1 still running", + " ◎ command still running", + " ◎ 1 a b c d still running", + ] { + assert_eq!(probe(row), None, "{row:?}"); + } + + // The probe is a single row: Worked-for closer to the box wins. + let mut rows = vec![ + " ◎ 1 subagent still running", + "", + " Worked for 8.7s", + "", + ]; + rows.extend(boxed); + assert_eq!( + GrokSummary.live_preview(&rs(&rows)), + Some(("Worked for 8.7s".to_string(), "grok:worked")) + ); +} + // ------------------------------------------------------- corpus replay -- /// Positive per-state fixtures at capture geometry (40×120): exact @@ -790,6 +857,13 @@ fn corpus_positive_states_anchor_exactly() { "Grok 4.5 (xhigh) · Worked for 8.7s", "grok:worked", ), + Case( + "preview_grok_still_running", + include_bytes!("../../tests/corpus/preview_grok_still_running.bin"), + &GrokSummary, + "Grok 4.5 (xhigh) · 1 subagent still running", + "grok:still-running", + ), ]; for Case(name, bytes, adapter, text, rule) in cases { let got = corpus(bytes, adapter, 120); @@ -887,6 +961,15 @@ fn corpus_body_shaped_text_never_extracts() { 120, ); assert_eq!(got, anchor("gpt-5.6-sol high · Working", "codex:working")); + + // Grok scrollback `Subagent running:` with no `◎` chrome: the probe + // is that body row and refuses, same marker fall-through as idle. + let got = corpus( + include_bytes!("../../tests/corpus/preview_grok_subagent_scrollback.bin"), + &GrokSummary, + 120, + ); + assert_eq!(got, marker()); } /// 80-column truncation: the CLIs cut their status rows at a word diff --git a/tests/corpus/README.md b/tests/corpus/README.md index 1881932..73e8616 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -68,6 +68,8 @@ roster below it. | `preview_claude_workflow_wait.bin` | claude waiting on a dynamic workflow, with 19 blank rows before the input box and a workflow roster below it | `claude:waiting` matches across the blank rows; the roster is excluded | | `preview_grok_working.bin` | grok braille spinner with elapsed/throughput ticker | `grok:spinner` cut at the label's `…`; border label read | | `preview_grok_worked.bin` | grok `Worked for 8.7s` completion row above the box | `grok:worked` kept verbatim | +| `preview_grok_still_running.bin` | grok `◎ 1 subagent still running` above the idle box | `grok:still-running`; border label read | +| `preview_grok_subagent_scrollback.bin` | grok idle with `Subagent running:` in the body, no `◎` row | fall-through; body-shaped text is not status | | `preview_grok_idle.bin` | grok idle session | fall-through to the marker | | `preview_grok_splash.bin` | grok launch splash with resume hint above the box | fall-through; distinct views never anchor | | `preview_trunc_claude.bin` | synthetic 40×80: spinner row truncated inside its parenthetical | head match still extracts `Hashing…` | diff --git a/tests/corpus/preview_grok_still_running.bin b/tests/corpus/preview_grok_still_running.bin new file mode 100644 index 0000000..b9bd28b --- /dev/null +++ b/tests/corpus/preview_grok_still_running.bin @@ -0,0 +1,39 @@ +[?1049h + ~/Projects/fixture-sources 3.0K / 500K + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ◎ 1 subagent still running + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ❯ │ + ╰────────────────────────────────────────────────────────────────────────────── Grok 4.5 (xhigh) · always-approve ─╯ + + Shift+Tab:mode │ Ctrl+x:shortcuts diff --git a/tests/corpus/preview_grok_subagent_scrollback.bin b/tests/corpus/preview_grok_subagent_scrollback.bin new file mode 100644 index 0000000..1c5f77b --- /dev/null +++ b/tests/corpus/preview_grok_subagent_scrollback.bin @@ -0,0 +1,39 @@ +[?1049h + ~/Projects/fixture-sources 3.0K / 500K + + + + + + + Subagent running: "do the thing" (Implementer · grok-4.5) — Thinking + + + + + + + + + + + + + + + + + + + + + + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ❯ │ + ╰────────────────────────────────────────────────────────────────────────────── Grok 4.5 (xhigh) · always-approve ─╯ + + Shift+Tab:mode │ Ctrl+x:shortcuts From 4e45dd048dc05bf5491214028d5ca57aa7020b00 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Wed, 12 Aug 2026 16:03:11 -0700 Subject: [PATCH 2/4] fix(harness): encode grok session groups the way 1.0.3 writes them --- docs/agent-resume.md | 2 +- src/harness/grok.rs | 311 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 287 insertions(+), 26 deletions(-) diff --git a/docs/agent-resume.md b/docs/agent-resume.md index f4e53e1..4fb8a7c 100644 --- a/docs/agent-resume.md +++ b/docs/agent-resume.md @@ -72,7 +72,7 @@ The exit scraper accepts `codex resume ` and `codex resume, then select '`, while a canonical resume command needs no instrumentation. -After exit, the harness scans retained terminal text for the last `grok -r ` or `grok --resume ` hint. Save-time filesystem correlation checks `/sessions///`, where `/` is encoded as `%2F` and `%` as `%25`. +After exit, the harness scans retained terminal text for the last `grok -r ` or `grok --resume ` hint. Save-time filesystem correlation checks `/sessions///`, percent-encoding the canonical working directory, falling back to a group whose `.cwd` file names that path when the encoded name is too long, and ignoring `session_kind: subagent` directories. ## ID precedence diff --git a/src/harness/grok.rs b/src/harness/grok.rs index 35e6d5a..2ee69e3 100644 --- a/src/harness/grok.rs +++ b/src/harness/grok.rs @@ -1,11 +1,19 @@ //! Grok has no injectable live-capture channel. Bare launches instead pin a v4 //! UUID, and completed tasks expose either `grok -r ` or //! `grok --resume ` in terminal output. The filesystem fallback -//! correlates `/sessions///` directories. +//! correlates `/sessions///` directories: the group is +//! a percent-encoding of the working directory, or a long-name slug whose +//! `.cwd` file names that path. -use std::{path::Path, time::SystemTime}; +use std::{ + fs, + path::{Path, PathBuf}, + time::{Duration, SystemTime}, +}; -use super::{CapturePaths, Harness, Invocation, SpawnPlan, last_hint, pin_plan, unique_in_window}; +use super::{ + CapturePaths, Harness, Invocation, SpawnPlan, is_uuid, last_hint, pin_plan, within_window, +}; pub struct Grok; @@ -43,37 +51,207 @@ impl Harness for Grok { } fn correlate_fs(&self, cwd: &Path, spawned: SystemTime, home: Option<&Path>) -> Option { - let dir = self - .home_root(home)? - .join("sessions") - .join(encode_cwd(cwd)?); - unique_in_window(dir, spawned, |entry| { - // One directory per session, named by its uuid. Files such as - // the `prompt_history.jsonl` sibling are not sessions. - if !entry.file_type().is_ok_and(|t| t.is_dir()) { - return None; - } - Some(entry.file_name().to_str()?.to_string()) - }) + let group = unique_group(&self.home_root(home)?.join("sessions"), cwd)?; + unique_session(&group, spawned) } } -/// Encode an absolute working directory as a Grok session-store key. `/` -/// becomes `%2F`, and `%` becomes `%25` to keep the mapping injective. -/// Non-UTF-8 paths have no representable key. +/// Byte-wise URL-encode of a working directory as Grok's session group name. +/// RFC 3986 unreserved bytes stay literal; every other byte becomes uppercase +/// `%XX`. Non-UTF-8 paths have no key. The path is encoded as given. fn encode_cwd(cwd: &Path) -> Option { let s = cwd.to_str()?; - let mut out = String::with_capacity(s.len() + 16); - for c in s.chars() { - match c { - '/' => out.push_str("%2F"), - '%' => out.push_str("%25"), - c => out.push(c), + let mut out = String::with_capacity(s.len() * 3); + for &b in s.as_bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') { + out.push(b as char); + } else { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + out.push('%'); + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); } } Some(out) } +/// The one sessions subdirectory for `cwd`. Several distinct matches cannot +/// be told apart: a wrong group is worse than none. +fn unique_group(sessions: &Path, cwd: &Path) -> Option { + let mut found: Vec = Vec::new(); + + if let Some(p) = encoded_dir(sessions, cwd) { + push_unique(&mut found, p); + } + let canon = cwd.canonicalize().ok(); + if let Some(ref canon) = canon + && canon.as_path() != cwd + && let Some(p) = encoded_dir(sessions, canon) + { + push_unique(&mut found, p); + } + + let given = cwd.to_str(); + let canon_s = canon.as_ref().and_then(|p| p.to_str()); + if let Ok(entries) = fs::read_dir(sessions) { + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let Ok(text) = fs::read_to_string(entry.path().join(".cwd")) else { + continue; + }; + let trimmed = text.trim(); + if given == Some(trimmed) || canon_s == Some(trimmed) { + push_unique(&mut found, entry.path()); + } + } + } + + match found.as_slice() { + [only] => Some(only.clone()), + _ => None, + } +} + +fn push_unique(found: &mut Vec, p: PathBuf) { + if !found.contains(&p) { + found.push(p); + } +} + +fn encoded_dir(sessions: &Path, cwd: &Path) -> Option { + let p = sessions.join(encode_cwd(cwd)?); + p.is_dir().then_some(p) +} + +/// The one in-window top-level session directory under `group`. Subagent +/// siblings do not count. A unique non-uuid name still yields `None`. +fn unique_session(group: &Path, spawned: SystemTime) -> Option { + let mut candidates: Vec = Vec::new(); + for entry in fs::read_dir(group).ok()?.flatten() { + // One directory per session, named by its uuid. Files such as + // the `prompt_history.jsonl` sibling are not sessions. + if !entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let summary = fs::read_to_string(entry.path().join("summary.json")) + .ok() + .and_then(|text| jzon::parse(&text).ok()); + if summary + .as_ref() + .is_some_and(|v| v["session_kind"].as_str() == Some("subagent")) + { + continue; + } + let ts = summary + .as_ref() + .and_then(|v| v["created_at"].as_str()) + .and_then(parse_created_at) + .or_else(|| entry.metadata().ok()?.created().ok()); + let Some(ts) = ts else { + continue; + }; + if !within_window(ts, spawned) { + continue; + } + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + candidates.push(name.to_string()); + } + match candidates.as_slice() { + [only] if is_uuid(only) => Some(only.clone()), + _ => None, + } +} + +/// `YYYY-MM-DDTHH:MM:SS[.frac]Z` as grok writes `created_at`. Any other shape +/// fails so the caller can fall back to directory birth time. +fn parse_created_at(s: &str) -> Option { + let s = s.strip_suffix('Z')?; + let (head, frac) = match s.split_once('.') { + Some((h, f)) => (h, Some(f)), + None => (s, None), + }; + let b = head.as_bytes(); + if b.len() != 19 + || b[4] != b'-' + || b[7] != b'-' + || b[10] != b'T' + || b[13] != b':' + || b[16] != b':' + { + return None; + } + let year = parse_digits(&head[..4])?; + let month = u32::try_from(parse_digits(&head[5..7])?).ok()?; + let day = u32::try_from(parse_digits(&head[8..10])?).ok()?; + let hour = u32::try_from(parse_digits(&head[11..13])?).ok()?; + let minute = u32::try_from(parse_digits(&head[14..16])?).ok()?; + let second = u32::try_from(parse_digits(&head[17..19])?).ok()?; + let nanos = match frac { + None => 0, + Some(f) if !f.is_empty() && f.bytes().all(|c| c.is_ascii_digit()) => frac_nanos(f)?, + _ => return None, + }; + if !valid_ymd(year, month, day) || hour > 23 || minute > 59 || second > 59 { + return None; + } + let days = days_from_civil(year, month, day); + let day_secs = i64::from(hour) * 3600 + i64::from(minute) * 60 + i64::from(second); + let secs = u64::try_from(days.checked_mul(86_400)?.checked_add(day_secs)?).ok()?; + SystemTime::UNIX_EPOCH.checked_add(Duration::new(secs, nanos)) +} + +fn parse_digits(s: &str) -> Option { + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse().ok() +} + +fn frac_nanos(frac: &str) -> Option { + let take = frac.len().min(9); + let mut n: u32 = frac[..take].parse().ok()?; + for _ in take..9 { + n = n.checked_mul(10)?; + } + Some(n) +} + +fn valid_ymd(year: i64, month: u32, day: u32) -> bool { + let mdays = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if year.rem_euclid(4) == 0 && (year.rem_euclid(100) != 0 || year.rem_euclid(400) == 0) { + 29 + } else { + 28 + } + } + _ => return false, + }; + (1..=mdays).contains(&day) +} + +/// Inverse of [`crate::format::civil_from_days`]: days since 1970-01-01. +fn days_from_civil(year: i64, month: u32, day: u32) -> i64 { + let y = if month <= 2 { year - 1 } else { year }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = if month > 2 { + i64::from(month) - 3 + } else { + i64::from(month) + 9 + }; + let doy = (153 * mp + 2) / 5 + i64::from(day) - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + #[cfg(test)] mod tests { use std::fs; @@ -158,7 +336,7 @@ mod tests { assert_eq!(Grok.scrape_exit(&format!("grok -r {ID}ff")), None); } - /// Store keys encode slashes and percent signs while preserving dots. + /// Store keys percent-encode every non-unreserved byte, uppercase hex. #[test] fn encode_cwd_matches_the_observed_store_names() { assert_eq!( @@ -172,6 +350,12 @@ mod tests { ); // A literal `%` must encode for the mapping to stay injective. assert_eq!(encode_cwd(Path::new("/a%b")).as_deref(), Some("%2Fa%25b")); + assert_eq!( + encode_cwd(Path::new("/has space")).as_deref(), + Some("%2Fhas%20space") + ); + assert_eq!(encode_cwd(Path::new("/a+b")).as_deref(), Some("%2Fa%2Bb")); + assert_eq!(encode_cwd(Path::new("/ü")).as_deref(), Some("%2F%C3%BC")); } #[test] @@ -211,6 +395,83 @@ mod tests { assert_eq!(Grok.correlate_fs(cwd, SystemTime::now(), Some(&home)), None); } + /// `created_at` as grok writes it: 2026-07-15 is day 20_649 since epoch. + const GROK_CREATED_AT: &str = "2026-07-15T00:34:19.339081Z"; + + fn spec_spawned() -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::new(20_649 * 86_400 + 34 * 60 + 19, 339_081_000) + } + + fn write_summary(dir: &Path, id: &str, cwd: &str, subagent: bool) { + let kind = if subagent { + r#","session_kind":"subagent""# + } else { + "" + }; + fs::write( + dir.join("summary.json"), + format!( + r#"{{"info":{{"id":"{id}","cwd":"{cwd}"}},"created_at":"{GROK_CREATED_AT}"{kind}}}"# + ), + ) + .unwrap(); + } + + #[test] + fn correlate_fs_reads_a_long_name_group_via_dot_cwd() { + let home = temp("grok_longpath"); + let cwd = Path::new("/work/very-long-path-name-that-would-encode-past-the-limit"); + let group = home + .join("sessions") + .join("would-encode-past-the-limit-0123456789abcdef"); + fs::create_dir_all(group.join(ID)).unwrap(); + fs::write(group.join(".cwd"), format!("{}\n", cwd.display())).unwrap(); + write_summary(&group.join(ID), ID, cwd.to_str().unwrap(), false); + assert_eq!( + Grok.correlate_fs(cwd, spec_spawned(), Some(&home)) + .as_deref(), + Some(ID) + ); + } + + #[test] + fn correlate_fs_follows_a_symlink_cwd_to_the_canonical_group() { + let tmp = temp("grok_canon"); + let real = tmp.join("real"); + let link = tmp.join("link"); + let home = tmp.join("home"); + fs::create_dir_all(&real).unwrap(); + fs::create_dir_all(&home).unwrap(); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let canonical = real.canonicalize().unwrap(); + let group = home + .join("sessions") + .join(encode_cwd(&canonical).expect("canonical path is UTF-8")); + fs::create_dir_all(group.join(ID)).unwrap(); + write_summary(&group.join(ID), ID, canonical.to_str().unwrap(), false); + assert_eq!( + Grok.correlate_fs(&link, spec_spawned(), Some(&home)) + .as_deref(), + Some(ID) + ); + } + + #[test] + fn correlate_fs_ignores_an_in_window_subagent_sibling() { + let home = temp("grok_subagent"); + let cwd = Path::new("/work/proj.rs"); + let dir = home.join("sessions").join("%2Fwork%2Fproj.rs"); + fs::create_dir_all(dir.join(ID)).unwrap(); + fs::create_dir_all(dir.join(OTHER)).unwrap(); + write_summary(&dir.join(ID), ID, "/work/proj.rs", false); + write_summary(&dir.join(OTHER), OTHER, "/work/proj.rs", true); + assert_eq!( + Grok.correlate_fs(cwd, spec_spawned(), Some(&home)) + .as_deref(), + Some(ID) + ); + } + /// The scraper recovers the exit-hint ID from the corpus terminal bytes. #[test] fn corpus_scrape_recovers_the_exit_hint_id() { From aefe182787f75622045b4ec262a625812b7709ef Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Wed, 12 Aug 2026 16:11:37 -0700 Subject: [PATCH 3/4] test(supervisor): grok scrape and filesystem resume through the recipe path --- src/harness/grok.rs | 2 +- src/harness/mod.rs | 2 + src/supervisor_capture_tests.rs | 241 ++++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 1 deletion(-) diff --git a/src/harness/grok.rs b/src/harness/grok.rs index 2ee69e3..00740fa 100644 --- a/src/harness/grok.rs +++ b/src/harness/grok.rs @@ -59,7 +59,7 @@ impl Harness for Grok { /// Byte-wise URL-encode of a working directory as Grok's session group name. /// RFC 3986 unreserved bytes stay literal; every other byte becomes uppercase /// `%XX`. Non-UTF-8 paths have no key. The path is encoded as given. -fn encode_cwd(cwd: &Path) -> Option { +pub(crate) fn encode_cwd(cwd: &Path) -> Option { let s = cwd.to_str()?; let mut out = String::with_capacity(s.len() * 3); for &b in s.as_bytes() { diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 382334c..7588d1b 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -32,6 +32,8 @@ use std::{ pub use claude::Claude; pub use codex::Codex; pub use grok::Grok; +#[cfg(test)] +pub(crate) use grok::encode_cwd; /// Environment variable naming the capture file used by injected assets. pub const CAPTURE_ENV: &str = "FLEETCOM_CAPTURE_FILE"; diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index 40869d0..e195997 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -518,6 +518,247 @@ fn spawn_grok_pins_an_id_and_injects_nothing_else() { ); } +/// A `grok` exit hint becomes the session ID used by the saved recipe. +/// The spawn pin stays; scrape must outrank it. +#[test] +fn grok_exit_hint_is_scraped_and_saved_as_a_resume() { + let dir = scratch("grok_scrape_exit"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + install_script( + &bin, + "grok", + &format!("printf 'Resume this session with:\\ngrok --resume {CAP_ID}\\n'"), + ); + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[ + ("FLEETCOM_CONFIG_DIR", &config), + ("GROK_HOME", &dir.join("grok_home")), + ], + )); + spawn(&mut s, "grok", dir.to_path_buf()); + assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] + .scraped_id + .is_some())); + assert_eq!(s.tasks[0].scraped_id.as_deref(), Some(CAP_ID)); + assert!( + s.tasks[0].resume_id.is_some(), + "the spawn pin stays; scrape must outrank it" + ); + assert_ne!(s.tasks[0].resume_id.as_deref(), Some(CAP_ID)); + + let text = save_and_read(&mut s, &config, "hint"); + assert!( + text.contains(&format!("grok --resume '{CAP_ID}'")), + "the recipe must resume the scraped session; got {text}" + ); +} + +/// Saving between process exit and the next reap tick still captures the +/// grok exit hint because `save_session` performs its own ready scrape. +#[test] +fn save_scrapes_a_finished_grok_task_without_reap() { + let dir = scratch("grok_save_sync_scrape"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + install_script( + &bin, + "grok", + &format!("printf 'Resume this session with:\\ngrok --resume {CAP_ID}\\n'"), + ); + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[ + ("FLEETCOM_CONFIG_DIR", &config), + ("GROK_HOME", &dir.join("grok_home")), + ], + )); + spawn(&mut s, "grok", dir.to_path_buf()); + + assert!( + wait_until(Duration::from_secs(5), || s.tasks[0].reader_done()), + "the stub never reached EOF" + ); + assert!(s.tasks[0].finished.is_none(), "no reap may have run yet"); + + let text = save_and_read(&mut s, &config, "syncsave"); + assert!( + text.contains(&format!("grok --resume '{CAP_ID}'")), + "save must scrape the finished task itself; got {text}" + ); + assert_eq!(s.tasks[0].scraped_id.as_deref(), Some(CAP_ID)); +} + +/// Rerunning between process exit and the next reap tick latches the exit, +/// scrapes the grok hint, and resumes that session. +#[test] +fn rerun_scrapes_a_finished_grok_task_without_reap() { + let dir = scratch("grok_rerun_sync_scrape"); + let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); + install_script( + &bin, + "grok", + &format!("printf 'Resume this session with:\\ngrok --resume {CAP_ID}\\n'"), + ); + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[ + ("FLEETCOM_CONFIG_DIR", &config), + ("GROK_HOME", &dir.join("grok_home")), + ], + )); + spawn(&mut s, "grok", dir.to_path_buf()); + let id = s.tasks[0].id; + + assert!( + wait_until(Duration::from_secs(5), || s.tasks[0].reader_done()), + "the stub never reached EOF" + ); + assert!(s.tasks[0].finished.is_none(), "no reap may have run yet"); + + s.apply(Command::Restart { id }); + assert_eq!( + s.tasks[0].command, + format!("grok --resume '{CAP_ID}'"), + "rerun must compute its resume command from the exit scrape" + ); +} + +/// A silent grok task falls back to one in-window top-level session dir +/// under `GROK_HOME` when live channels produce no ID. +#[test] +fn save_falls_back_to_fs_correlation_for_a_silent_grok() { + let dir = scratch("grok_correlate_save"); + let (bin, runtime, config, grok_home) = ( + dir.join("bin"), + dir.join("run"), + dir.join("config"), + dir.join("grok_home"), + ); + install_stub(&bin, "grok", &dir); + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[("FLEETCOM_CONFIG_DIR", &config), ("GROK_HOME", &grok_home)], + )); + spawn(&mut s, "grok", dir.to_path_buf()); + assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] + .finished + .is_some())); + assert!(s.tasks[0].scraped_id.is_none(), "a silent exit has no hint"); + // Bare grok always pins; recipe_command would take that ID and never + // reach correlate_fs unless the pin is absent. + s.tasks[0].resume_id = None; + assert!( + current_resume_id(&s.tasks[0]).is_none(), + "clearing the pin is what exposes filesystem correlation" + ); + + let group = grok_home + .join("sessions") + .join(crate::harness::encode_cwd(&s.tasks[0].cwd).expect("task cwd is UTF-8")); + std::fs::create_dir_all(group.join(CAP_ID)).unwrap(); + + let text = save_and_read(&mut s, &config, "corr"); + assert!( + text.contains(&format!("grok --resume '{CAP_ID}'")), + "save must fall back to filesystem correlation; got {text}" + ); +} + +/// An in-window `session_kind: subagent` sibling does not steal uniqueness +/// from the top-level session directory. +#[test] +fn save_ignores_an_in_window_grok_subagent_sibling() { + let dir = scratch("grok_correlate_subagent"); + let (bin, runtime, config, grok_home) = ( + dir.join("bin"), + dir.join("run"), + dir.join("config"), + dir.join("grok_home"), + ); + install_stub(&bin, "grok", &dir); + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[("FLEETCOM_CONFIG_DIR", &config), ("GROK_HOME", &grok_home)], + )); + spawn(&mut s, "grok", dir.to_path_buf()); + assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] + .finished + .is_some())); + assert!(s.tasks[0].scraped_id.is_none(), "a silent exit has no hint"); + s.tasks[0].resume_id = None; + + let group = grok_home + .join("sessions") + .join(crate::harness::encode_cwd(&s.tasks[0].cwd).expect("task cwd is UTF-8")); + std::fs::create_dir_all(group.join(CAP_ID)).unwrap(); + // No created_at: birthtime is in-window, so the skip is session_kind. + let other = group.join(CAP_OTHER); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write(other.join("summary.json"), r#"{"session_kind":"subagent"}"#).unwrap(); + + let text = save_and_read(&mut s, &config, "subagent"); + assert!( + text.contains(&format!("grok --resume '{CAP_ID}'")), + "the recipe must resume the top-level session; got {text}" + ); + assert!( + !text.contains(CAP_OTHER), + "an in-window subagent sibling must not correlate; got {text}" + ); +} + +/// A spawn through a symlink cwd correlates against the canonical group's +/// encoded name. +#[test] +fn save_follows_a_symlink_cwd_to_the_canonical_grok_group() { + let dir = scratch("grok_correlate_symlink"); + let (bin, runtime, config, grok_home, real, link) = ( + dir.join("bin"), + dir.join("run"), + dir.join("config"), + dir.join("grok_home"), + dir.join("real"), + dir.join("link"), + ); + std::fs::create_dir_all(&real).unwrap(); + std::os::unix::fs::symlink(&real, &link).unwrap(); + install_stub(&bin, "grok", &dir); + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[("FLEETCOM_CONFIG_DIR", &config), ("GROK_HOME", &grok_home)], + )); + spawn(&mut s, "grok", link); + assert!(reap_until(&mut s, Duration::from_secs(5), |s| s.tasks[0] + .finished + .is_some())); + assert!(s.tasks[0].scraped_id.is_none(), "a silent exit has no hint"); + s.tasks[0].resume_id = None; + + let canonical = real.canonicalize().unwrap(); + let group = grok_home + .join("sessions") + .join(crate::harness::encode_cwd(&canonical).expect("canonical path is UTF-8")); + std::fs::create_dir_all(group.join(CAP_ID)).unwrap(); + + let text = save_and_read(&mut s, &config, "symlink"); + assert!( + text.contains(&format!("grok --resume '{CAP_ID}'")), + "save must correlate through the canonical group; got {text}" + ); +} + /// A `claude` exit hint becomes the session ID used by the saved recipe. #[test] fn exit_hint_is_scraped_and_saved_as_a_resume() { From 2cae7ac97b9735e3dbdc6aa7625fb3a91a0c8278 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Wed, 12 Aug 2026 16:40:22 -0700 Subject: [PATCH 4/4] refactor(harness): replace unique_group with matching_groups for session correlation --- src/harness/grok.rs | 151 ++++++++++++++++++++++++++++++++------------ 1 file changed, 109 insertions(+), 42 deletions(-) diff --git a/src/harness/grok.rs b/src/harness/grok.rs index 00740fa..bfca0e0 100644 --- a/src/harness/grok.rs +++ b/src/harness/grok.rs @@ -51,8 +51,8 @@ impl Harness for Grok { } fn correlate_fs(&self, cwd: &Path, spawned: SystemTime, home: Option<&Path>) -> Option { - let group = unique_group(&self.home_root(home)?.join("sessions"), cwd)?; - unique_session(&group, spawned) + let groups = matching_groups(&self.home_root(home)?.join("sessions"), cwd); + unique_session(&groups, spawned) } } @@ -75,9 +75,11 @@ pub(crate) fn encode_cwd(cwd: &Path) -> Option { Some(out) } -/// The one sessions subdirectory for `cwd`. Several distinct matches cannot -/// be told apart: a wrong group is worse than none. -fn unique_group(sessions: &Path, cwd: &Path) -> Option { +/// Session-store groups that name `cwd`: the encoded given path, the encoded +/// canonical path when it differs, and any directory whose `.cwd` record is +/// that path. Same path through two aliases is one group. Distinct folders +/// stay in the list; uniqueness is decided on in-window sessions, not here. +fn matching_groups(sessions: &Path, cwd: &Path) -> Vec { let mut found: Vec = Vec::new(); if let Some(p) = encoded_dir(sessions, cwd) { @@ -101,17 +103,22 @@ fn unique_group(sessions: &Path, cwd: &Path) -> Option { let Ok(text) = fs::read_to_string(entry.path().join(".cwd")) else { continue; }; - let trimmed = text.trim(); - if given == Some(trimmed) || canon_s == Some(trimmed) { + let record = cwd_record(&text); + if given == Some(record) || canon_s == Some(record) { push_unique(&mut found, entry.path()); } } } + found +} - match found.as_slice() { - [only] => Some(only.clone()), - _ => None, - } +/// The path grok stored in `.cwd`: the file bytes minus one trailing `\n`, +/// and a `\r` immediately before that `\n` if present. Interior and leading +/// spaces stay; they are part of the directory name. +fn cwd_record(text: &str) -> &str { + text.strip_suffix('\n') + .map(|s| s.strip_suffix('\r').unwrap_or(s)) + .unwrap_or(text) } fn push_unique(found: &mut Vec, p: PathBuf) { @@ -125,41 +132,49 @@ fn encoded_dir(sessions: &Path, cwd: &Path) -> Option { p.is_dir().then_some(p) } -/// The one in-window top-level session directory under `group`. Subagent -/// siblings do not count. A unique non-uuid name still yields `None`. -fn unique_session(group: &Path, spawned: SystemTime) -> Option { +/// The one in-window top-level session across `groups`. Subagent siblings +/// do not count. The same uuid in two groups is one candidate. A unique +/// non-uuid name still yields `None`. +fn unique_session(groups: &[PathBuf], spawned: SystemTime) -> Option { let mut candidates: Vec = Vec::new(); - for entry in fs::read_dir(group).ok()?.flatten() { - // One directory per session, named by its uuid. Files such as - // the `prompt_history.jsonl` sibling are not sessions. - if !entry.file_type().is_ok_and(|t| t.is_dir()) { - continue; - } - let summary = fs::read_to_string(entry.path().join("summary.json")) - .ok() - .and_then(|text| jzon::parse(&text).ok()); - if summary - .as_ref() - .is_some_and(|v| v["session_kind"].as_str() == Some("subagent")) - { - continue; - } - let ts = summary - .as_ref() - .and_then(|v| v["created_at"].as_str()) - .and_then(parse_created_at) - .or_else(|| entry.metadata().ok()?.created().ok()); - let Some(ts) = ts else { + for group in groups { + let Ok(entries) = fs::read_dir(group) else { continue; }; - if !within_window(ts, spawned) { - continue; + for entry in entries.flatten() { + // One directory per session, named by its uuid. Files such as + // the `prompt_history.jsonl` sibling are not sessions. + if !entry.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + let summary = fs::read_to_string(entry.path().join("summary.json")) + .ok() + .and_then(|text| jzon::parse(&text).ok()); + if summary + .as_ref() + .is_some_and(|v| v["session_kind"].as_str() == Some("subagent")) + { + continue; + } + let ts = summary + .as_ref() + .and_then(|v| v["created_at"].as_str()) + .and_then(parse_created_at) + .or_else(|| entry.metadata().ok()?.created().ok()); + let Some(ts) = ts else { + continue; + }; + if !within_window(ts, spawned) { + continue; + } + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if !candidates.iter().any(|c| c == name) { + candidates.push(name.to_string()); + } } - let name = entry.file_name(); - let Some(name) = name.to_str() else { - continue; - }; - candidates.push(name.to_string()); } match candidates.as_slice() { [only] if is_uuid(only) => Some(only.clone()), @@ -472,6 +487,58 @@ mod tests { ); } + /// A leftover group that also names `cwd` does not hide the one + /// in-window session in the other group. + #[test] + fn correlate_fs_accepts_one_in_window_session_across_two_groups() { + let home = temp("grok_stale_group"); + let cwd = Path::new("/work/proj.rs"); + let live = home.join("sessions").join("%2Fwork%2Fproj.rs"); + let stale = home.join("sessions").join("stale-alias-0123456789abcdef"); + fs::create_dir_all(live.join(ID)).unwrap(); + fs::create_dir_all(&stale).unwrap(); + fs::write(stale.join(".cwd"), "/work/proj.rs\n").unwrap(); + write_summary(&live.join(ID), ID, "/work/proj.rs", false); + assert_eq!( + Grok.correlate_fs(cwd, spec_spawned(), Some(&home)) + .as_deref(), + Some(ID) + ); + + fs::create_dir_all(stale.join(OTHER)).unwrap(); + write_summary(&stale.join(OTHER), OTHER, "/work/proj.rs", false); + assert_eq!(Grok.correlate_fs(cwd, spec_spawned(), Some(&home)), None); + } + + #[test] + fn cwd_record_strips_only_one_newline_terminator() { + assert_eq!(cwd_record("/work/proj.rs"), "/work/proj.rs"); + assert_eq!(cwd_record("/work/proj.rs\n"), "/work/proj.rs"); + assert_eq!(cwd_record("/work/proj.rs\r\n"), "/work/proj.rs"); + assert_eq!(cwd_record("/work/project \n"), "/work/project "); + assert_eq!(cwd_record(" /work/lead"), " /work/lead"); + } + + /// A `.cwd` path with trailing spaces is a different directory. + #[test] + fn correlate_fs_does_not_trim_cwd_record_spaces() { + let home = temp("grok_cwd_spaces"); + let spaced = Path::new("/work/project "); + let group = home.join("sessions").join("spaced-cwd-0123456789abcdef"); + fs::create_dir_all(group.join(ID)).unwrap(); + fs::write(group.join(".cwd"), "/work/project \n").unwrap(); + write_summary(&group.join(ID), ID, "/work/project ", false); + assert_eq!( + Grok.correlate_fs(spaced, spec_spawned(), Some(&home)) + .as_deref(), + Some(ID) + ); + assert_eq!( + Grok.correlate_fs(Path::new("/work/project"), spec_spawned(), Some(&home)), + None + ); + } + /// The scraper recovers the exit-hint ID from the corpus terminal bytes. #[test] fn corpus_scrape_recovers_the_exit_hint_id() {