1use crate::userdata::catalog::scan::{
6 SessionTagCache, project_key_for_directory, tag_and_project_of,
7};
8use forge_primitives::worker_tag;
9use forge_sdk::projects_dir_for;
10use std::path::{Path, PathBuf};
11use std::time::SystemTime;
12
13#[derive(Debug, thiserror::Error)]
15pub enum TranscriptError {
16 #[error("could not read {path}: {source}")]
23 Unreadable { path: PathBuf, source: std::io::Error },
24}
25
26pub fn newest_worker_session(
41 config_dir: &Path,
42 run_dir: &Path,
43 label: &str,
44 project: &str,
45 tag_cache: Option<&SessionTagCache>,
46) -> Result<Option<(String, bool)>, TranscriptError> {
47 let key = project_key_for_directory(Some(&run_dir.to_string_lossy()));
48 let dir = projects_dir_for(config_dir).join(key);
49 let entries = match std::fs::read_dir(&dir) {
50 Ok(entries) => entries,
51 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
52 Err(source) => return Err(TranscriptError::Unreadable { path: dir, source }),
53 };
54 let wanted = worker_tag(label);
55 let mut newest: Option<(SystemTime, String, bool)> = None;
56 for entry in entries {
57 let entry =
58 entry.map_err(|source| TranscriptError::Unreadable { path: dir.clone(), source })?;
59 let path = entry.path();
60 if path.extension().and_then(std::ffi::OsStr::to_str) != Some("jsonl") {
61 continue;
62 }
63 let Some(session_id) = path.file_stem().and_then(std::ffi::OsStr::to_str) else {
64 continue;
65 };
66 if session_id.is_empty() {
67 continue;
68 }
69 let metadata = entry
70 .metadata()
71 .map_err(|source| TranscriptError::Unreadable { path: path.clone(), source })?;
72 let modified = metadata
73 .modified()
74 .map_err(|source| TranscriptError::Unreadable { path: path.clone(), source })?;
75 let candidate = (modified, session_id);
80 if newest.as_ref().is_some_and(|(best, id, _)| candidate <= (*best, id.as_str())) {
81 continue;
82 }
83 let (tag, owner) = read_tag(&path, metadata.len(), tag_cache)?;
84 if tag.as_deref() != Some(wanted.as_str()) {
85 continue;
86 }
87 if owner.as_deref().is_some_and(|owner| owner != project) {
88 continue;
89 }
90 newest = Some((modified, session_id.to_owned(), owner.is_none()));
91 }
92 Ok(newest.map(|(_, session_id, restamp)| (session_id, restamp)))
93}
94
95fn read_tag(
98 path: &Path,
99 size: u64,
100 tag_cache: Option<&SessionTagCache>,
101) -> Result<(Option<String>, Option<String>), TranscriptError> {
102 let unreadable = |source| TranscriptError::Unreadable { path: path.to_path_buf(), source };
103 let mut file = std::fs::File::open(path).map_err(unreadable)?;
104 tag_and_project_of(path, &mut file, size, tag_cache).map_err(unreadable)
105}
106
107#[cfg(test)]
108mod tests {
109
110 use super::*;
111 use std::ffi::OsStr;
112 use std::io::Write as _;
113 use std::time::{Duration, UNIX_EPOCH};
114
115 #[test]
116 fn the_newest_session_tagged_for_the_label_wins() {
117 let root = tempfile::tempdir().expect("config dir");
118 let run_dir = Path::new("/p/.claude/worktrees/w");
119 let dir = transcript_dir(root.path(), run_dir);
120 for session_id in ["aaaa", "bbbb", "cccc"] {
121 write_session(&dir, session_id, "steward", 1_700_000_000);
122 }
123 let listed = session_ids(&dir);
128 let newest = listed[1].clone();
129 write_session(&dir, &newest, "steward", 1_800_000_000);
130 let found = newest_worker_session(root.path(), run_dir, "steward", "Default/forge", None)
131 .expect("lookup");
132 assert_eq!(
133 found,
134 Some((newest, true)),
135 "the label's most recent session is the one to resume, and its pre-field row needs \
136 stamping",
137 );
138 }
139
140 #[test]
145 fn a_newer_transcript_that_is_not_the_labels_is_not_resumed() {
146 let root = tempfile::tempdir().expect("config dir");
147 let run_dir = Path::new("/p/.claude/worktrees/w");
148 let dir = transcript_dir(root.path(), run_dir);
149 write_session(&dir, "mine", "steward", 1_700_000_000);
150 write_session(&dir, "other-worker", "reviewer", 1_800_000_000);
151 write_untagged_session(&dir, "untagged", 1_900_000_000);
152 let found = newest_worker_session(root.path(), run_dir, "steward", "Default/forge", None)
153 .expect("lookup");
154 assert_eq!(found, Some(("mine".to_owned(), true)));
155 }
156
157 #[test]
162 fn another_projects_session_in_the_shared_directory_is_not_resumed() {
163 let root = tempfile::tempdir().expect("config dir");
164 let run_dir = Path::new("/p/.claude/worktrees/w");
165 let dir = transcript_dir(root.path(), run_dir);
166 write_project_session(&dir, "theirs", "steward", "Default/proj-a", 1_800_000_000);
167 write_project_session(&dir, "mine", "steward", "Default/proj-b", 1_700_000_000);
168 write_session(&dir, "old", "steward", 1_600_000_000);
169
170 let found = newest_worker_session(root.path(), run_dir, "steward", "Default/proj-b", None)
171 .expect("lookup");
172 assert_eq!(
173 found,
174 Some(("mine".to_owned(), false)),
175 "proj-a's newer session is skipped in the directory both projects compose",
176 );
177
178 std::fs::remove_file(dir.join("mine.jsonl")).expect("remove mine");
179 let found = newest_worker_session(root.path(), run_dir, "steward", "Default/proj-b", None)
180 .expect("lookup");
181 assert_eq!(
182 found,
183 Some(("old".to_owned(), true)),
184 "a tag row with no project field stays eligible and is flagged for stamping",
185 );
186 }
187
188 #[test]
194 fn a_tie_on_mtime_is_broken_by_the_session_id() {
195 let root = tempfile::tempdir().expect("config dir");
196 let run_dir = Path::new("/p/.claude/worktrees/w");
197 let dir = transcript_dir(root.path(), run_dir);
198 for session_id in ["aaaa", "mmmm", "zzzz"] {
199 write_session(&dir, session_id, "steward", 1_700_000_000);
200 }
201 let found = newest_worker_session(root.path(), run_dir, "steward", "Default/forge", None)
202 .expect("lookup");
203 assert_eq!(found, Some(("zzzz".to_owned(), true)), "the greater id wins an mtime tie");
204 }
205
206 #[test]
207 fn a_transcript_that_is_not_a_session_is_not_resumed() {
208 let root = tempfile::tempdir().expect("config dir");
209 let run_dir = Path::new("/p/.claude/worktrees/w");
210 let dir = transcript_dir(root.path(), run_dir);
211 write_session(&dir, "mine", "steward", 1_700_000_000);
212 let tagged = "{\"type\":\"tag\",\"tag\":\"forge:worker:steward\"}\n";
215 for sibling in ["other.jsonl.partial", "other.jsonl.bak"] {
216 write_file(&dir.join(sibling), tagged, 1_900_000_000);
217 }
218 let found = newest_worker_session(root.path(), run_dir, "steward", "Default/forge", None)
219 .expect("lookup");
220 assert_eq!(found, Some(("mine".to_owned(), true)));
221 }
222
223 #[test]
224 fn a_directory_that_is_not_there_is_absence_not_failure() {
225 let root = tempfile::tempdir().expect("config dir");
226 let found = newest_worker_session(
227 root.path(),
228 Path::new("/p/.claude/worktrees/never"),
229 "steward",
230 "Default/forge",
231 None,
232 );
233 assert_eq!(found.expect("a missing directory is not a failure"), None);
234 }
235
236 fn transcript_dir(config_dir: &Path, run_dir: &Path) -> PathBuf {
238 let key = crate::userdata::catalog::scan::project_key_for_directory(Some(
239 &run_dir.to_string_lossy(),
240 ));
241 let dir = forge_sdk::projects_dir_for(config_dir).join(key);
242 std::fs::create_dir_all(&dir).expect("transcript dir");
243 dir
244 }
245
246 fn session_ids(dir: &Path) -> Vec<String> {
249 std::fs::read_dir(dir)
250 .expect("read transcript dir")
251 .map(|entry| entry.expect("dir entry").path())
252 .filter(|path| path.extension().and_then(OsStr::to_str) == Some("jsonl"))
253 .filter_map(|path| path.file_stem().and_then(OsStr::to_str).map(str::to_owned))
254 .collect()
255 }
256
257 fn write_session(dir: &Path, session_id: &str, label: &str, modified: u64) {
260 let tag = format!("{{\"type\":\"tag\",\"tag\":\"forge:worker:{label}\"}}\n");
261 write_file(&dir.join(format!("{session_id}.jsonl")), &tag, modified);
262 }
263
264 fn write_project_session(
266 dir: &Path,
267 session_id: &str,
268 label: &str,
269 project: &str,
270 modified: u64,
271 ) {
272 let tag = format!(
273 "{{\"type\":\"tag\",\"tag\":\"forge:worker:{label}\",\"project\":\"{project}\"}}\n"
274 );
275 write_file(&dir.join(format!("{session_id}.jsonl")), &tag, modified);
276 }
277
278 fn write_untagged_session(dir: &Path, session_id: &str, modified: u64) {
280 write_file(&dir.join(format!("{session_id}.jsonl")), "", modified);
281 }
282
283 fn write_file(path: &Path, body: &str, modified: u64) {
284 let mut file = std::fs::File::create(path).expect("create transcript");
285 file.write_all(format!("{{\"type\":\"user\"}}\n{body}").as_bytes())
286 .expect("write transcript");
287 file.set_modified(UNIX_EPOCH + Duration::from_secs(modified)).expect("set mtime");
288 }
289}