use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use serde::Deserialize;
use tokio::sync::Semaphore;
use tracing::{debug, warn};
const FORK_PARENT_BUDGET: Duration = Duration::from_millis(25);
const FORK_PARENT_CONCURRENCY: usize = 64;
fn discovery_semaphore() -> &'static Arc<Semaphore> {
static SEM: OnceLock<Arc<Semaphore>> = OnceLock::new();
SEM.get_or_init(|| Arc::new(Semaphore::new(FORK_PARENT_CONCURRENCY)))
}
const HEAD_BYTES: usize = 2 * 1024;
pub async fn discover_parent(cwd: &str, sid: &str) -> Option<String> {
let Some(projects_dir) = projects_dir_for(cwd) else {
debug!("fork-parent: no home dir; skipping");
return None;
};
let sid_owned = sid.to_owned();
let sem = discovery_semaphore().clone();
let Ok(permit) = sem.acquire_owned().await else {
debug!("fork-parent: discovery semaphore closed; skipping");
return None;
};
let deadline = Deadline::starting_now(FORK_PARENT_BUDGET);
let scan_deadline = deadline.clone();
let scan = async move {
let join = tokio::task::spawn_blocking(move || {
let result =
discover_parent_blocking_with_deadline(&projects_dir, &sid_owned, &scan_deadline);
drop(permit);
result
});
join.await.ok().flatten()
};
match tokio::time::timeout(FORK_PARENT_BUDGET, scan).await {
Ok(parent) => parent,
Err(_) => {
warn!(
budget_ms = FORK_PARENT_BUDGET.as_millis() as u64,
"fork-parent: scan exceeded budget; degrading to no parent",
);
None
}
}
}
#[cfg(test)]
pub(crate) fn discover_parent_blocking(projects_dir: &Path, sid: &str) -> Option<String> {
discover_parent_blocking_with_deadline(projects_dir, sid, &Deadline::infinite())
}
pub(crate) fn discover_parent_blocking_with_deadline(
projects_dir: &Path,
sid: &str,
deadline: &Deadline,
) -> Option<String> {
if deadline.exceeded() {
return None;
}
let transcript = projects_dir.join(format!("{sid}.jsonl"));
let head = read_head(&transcript, HEAD_BYTES)?;
if deadline.exceeded() {
return None;
}
let parent_uuid = extract_first_user_parent_uuid(&head)?;
find_owning_sid_with_deadline(projects_dir, sid, &parent_uuid, deadline)
}
#[derive(Clone)]
pub(crate) struct Deadline {
start: Instant,
budget: Duration,
}
impl Deadline {
fn starting_now(budget: Duration) -> Self {
Self {
start: Instant::now(),
budget,
}
}
#[cfg(test)]
fn infinite() -> Self {
Self {
start: Instant::now(),
budget: Duration::from_secs(u64::MAX / 2),
}
}
fn exceeded(&self) -> bool {
self.start.elapsed() >= self.budget
}
}
fn projects_dir_for(cwd: &str) -> Option<PathBuf> {
let home = dirs::home_dir()?;
let encoded = encode_cwd(cwd);
Some(home.join(".claude").join("projects").join(encoded))
}
pub fn encode_cwd(cwd: &str) -> String {
cwd.replace(['/', '.'], "-")
}
fn read_head(path: &Path, cap: usize) -> Option<Vec<u8>> {
use std::io::Read;
let mut f = std::fs::File::open(path).ok()?;
let mut buf = vec![0u8; cap];
let n = f.read(&mut buf).ok()?;
buf.truncate(n);
Some(buf)
}
pub(crate) fn extract_first_user_parent_uuid(bytes: &[u8]) -> Option<String> {
#[derive(Deserialize)]
struct Record {
#[serde(rename = "type")]
type_: Option<String>,
#[serde(rename = "parentUuid")]
parent_uuid: Option<String>,
}
let text = std::str::from_utf8(bytes).ok()?;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Ok(rec) = serde_json::from_str::<Record>(trimmed) else {
continue;
};
if rec.type_.as_deref() == Some("user") {
return rec.parent_uuid.filter(|s| !s.is_empty());
}
}
None
}
#[cfg(test)]
pub(crate) fn find_owning_sid(
projects_dir: &Path,
exclude_sid: &str,
parent_uuid: &str,
) -> Option<String> {
find_owning_sid_with_deadline(
projects_dir,
exclude_sid,
parent_uuid,
&Deadline::infinite(),
)
}
pub(crate) fn find_owning_sid_with_deadline(
projects_dir: &Path,
exclude_sid: &str,
parent_uuid: &str,
deadline: &Deadline,
) -> Option<String> {
let needle = format!(r#""uuid":"{parent_uuid}""#);
let entries = std::fs::read_dir(projects_dir).ok()?;
for entry in entries.flatten() {
if deadline.exceeded() {
return None;
}
let path = entry.path();
let Some(stem) = path
.file_name()
.and_then(|n| n.to_str())
.and_then(|s| s.strip_suffix(".jsonl"))
else {
continue;
};
if stem == exclude_sid {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
if find_subslice(&bytes, needle.as_bytes()) {
return Some(stem.to_owned());
}
}
None
}
fn find_subslice(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || needle.len() > haystack.len() {
return false;
}
haystack.windows(needle.len()).any(|w| w == needle)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn encode_cwd_replaces_slashes_and_dots_with_dashes() {
assert_eq!(
encode_cwd("/Users/matt/git/paper-forest/groves/sessions"),
"-Users-matt-git-paper-forest-groves-sessions",
);
assert_eq!(encode_cwd("/"), "-");
assert_eq!(encode_cwd("/Users/matt"), "-Users-matt");
assert_eq!(
encode_cwd("/Users/x/.claude/jobs/4f2d5d07/tmp/smoke-cwd"),
"-Users-x--claude-jobs-4f2d5d07-tmp-smoke-cwd",
);
assert_eq!(encode_cwd("/w/my.project"), "-w-my-project");
}
#[test]
fn extract_parent_uuid_finds_first_user_record() {
let head = br#"{"type":"summary","sessionId":"abc"}
{"type":"user","parentUuid":"PARENT-UUID-HERE","uuid":"child-1"}
{"type":"assistant","uuid":"child-2"}
"#;
let p = extract_first_user_parent_uuid(head);
assert_eq!(p.as_deref(), Some("PARENT-UUID-HERE"));
}
#[test]
fn extract_parent_uuid_returns_none_when_null() {
let head = br#"{"type":"user","parentUuid":null,"uuid":"root"}"#;
assert!(extract_first_user_parent_uuid(head).is_none());
}
#[test]
fn extract_parent_uuid_skips_non_user_records() {
let head = br#"{"type":"summary","parentUuid":"WRONG"}
{"type":"user","parentUuid":"RIGHT","uuid":"x"}
"#;
assert_eq!(
extract_first_user_parent_uuid(head).as_deref(),
Some("RIGHT"),
);
}
#[test]
fn extract_parent_uuid_returns_none_on_no_user_record() {
let head = br#"{"type":"summary"}
{"type":"assistant","uuid":"a"}
"#;
assert!(extract_first_user_parent_uuid(head).is_none());
}
#[test]
fn extract_parent_uuid_skips_truncated_tail_line() {
let head = br#"{"type":"user","parentUuid":"GOOD","uuid":"x"}
{"type":"user","parentUuid":"BAD","uuid":"y","tru"#;
assert_eq!(
extract_first_user_parent_uuid(head).as_deref(),
Some("GOOD"),
);
}
#[test]
fn find_owning_sid_returns_match() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("parent-sid.jsonl"),
r#"{"type":"user","uuid":"PARENT-UUID","parentUuid":null}
{"type":"assistant","uuid":"other"}"#,
)
.unwrap();
std::fs::write(
dir.path().join("child-sid.jsonl"),
r#"{"type":"user","uuid":"child-1","parentUuid":"PARENT-UUID"}"#,
)
.unwrap();
let got = find_owning_sid(dir.path(), "child-sid", "PARENT-UUID");
assert_eq!(got.as_deref(), Some("parent-sid"));
}
#[test]
fn find_owning_sid_returns_none_when_no_match() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("foo.jsonl"),
r#"{"type":"user","uuid":"different"}"#,
)
.unwrap();
assert!(find_owning_sid(dir.path(), "child", "PARENT-UUID").is_none());
}
#[test]
fn find_owning_sid_excludes_the_child_transcript() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("child.jsonl"),
r#"{"type":"user","uuid":"child-1","parentUuid":"PARENT-UUID"}"#,
)
.unwrap();
assert!(find_owning_sid(dir.path(), "child", "PARENT-UUID").is_none());
}
#[test]
fn discover_parent_blocking_end_to_end() {
let dir = tempfile::tempdir().unwrap();
let projects = dir.path();
std::fs::write(
projects.join("parent-sid.jsonl"),
r#"{"type":"summary"}
{"type":"user","uuid":"PARENT-UUID","parentUuid":null}
"#,
)
.unwrap();
std::fs::write(
projects.join("child-sid.jsonl"),
r#"{"type":"summary"}
{"type":"user","uuid":"child-1","parentUuid":"PARENT-UUID"}
"#,
)
.unwrap();
let got = discover_parent_blocking(projects, "child-sid");
assert_eq!(got.as_deref(), Some("parent-sid"));
}
#[test]
fn discover_parent_blocking_returns_none_for_root_session() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("root-sid.jsonl"),
r#"{"type":"user","uuid":"root-1","parentUuid":null}"#,
)
.unwrap();
assert!(discover_parent_blocking(dir.path(), "root-sid").is_none());
}
#[test]
fn discover_parent_blocking_missing_transcript_returns_none() {
let dir = tempfile::tempdir().unwrap();
assert!(discover_parent_blocking(dir.path(), "ghost").is_none());
}
#[test]
fn already_exceeded_deadline_short_circuits_scan() {
let dir = tempfile::tempdir().unwrap();
let projects = dir.path();
std::fs::write(
projects.join("parent.jsonl"),
r#"{"type":"user","uuid":"P","parentUuid":null}"#,
)
.unwrap();
std::fs::write(
projects.join("child.jsonl"),
r#"{"type":"user","uuid":"c","parentUuid":"P"}"#,
)
.unwrap();
let expired = Deadline {
start: Instant::now() - Duration::from_secs(1),
budget: Duration::from_millis(0),
};
let got = discover_parent_blocking_with_deadline(projects, "child", &expired);
assert!(got.is_none());
}
#[test]
fn find_owning_sid_with_deadline_bails_when_exceeded() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("parent-sid.jsonl"),
r#"{"type":"user","uuid":"PARENT-UUID","parentUuid":null}"#,
)
.unwrap();
let expired = Deadline {
start: Instant::now() - Duration::from_secs(1),
budget: Duration::from_millis(0),
};
let got = find_owning_sid_with_deadline(dir.path(), "child-sid", "PARENT-UUID", &expired);
assert!(got.is_none());
}
#[tokio::test]
async fn discover_parent_concurrency_cap_is_initialised() {
let sem = discovery_semaphore().clone();
assert_eq!(sem.available_permits(), FORK_PARENT_CONCURRENCY);
}
}