Skip to main content

forge_agent/userdata/
transcripts.rs

1//! Session transcripts on disk: the `.jsonl` files under the config
2//! dir's `projects/` tree, each directory named for the path the
3//! session ran in.
4
5use 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/// Why a transcript lookup could not answer.
14#[derive(Debug, thiserror::Error)]
15pub enum TranscriptError {
16    /// The directory, or a transcript in it, is there but could not be
17    /// read, so whether the label has a prior session is unknown. A
18    /// directory that is simply absent is `Ok(None)` instead - nothing
19    /// to resume is a fallback, while this is a failure: folded into the
20    /// fallback it would tell the caller there is nothing to resume,
21    /// which the lookup never answered.
22    #[error("could not read {path}: {source}")]
23    Unreadable { path: PathBuf, source: std::io::Error },
24}
25
26/// The newest transcript in `run_dir`'s directory that carries `label`'s
27/// worker tag AND was tagged by `project`: its session id and whether its
28/// row predates the project field (`true` means the adopting project must
29/// stamp it). `None` when the directory holds no match.
30///
31/// The tag is what says whose session a transcript is, not the
32/// directory: everything that ran with that cwd writes there, the
33/// worker's own subagents included, so the newest file in the directory
34/// is often not the worker's. The project (the tag row's `project`
35/// field) is the second half: the composed worktree directory is shared
36/// across a repository's projects, so a label alone would let another
37/// project resume this session. A tag row written before the field
38/// existed carries no project and stays eligible, flagged so the caller
39/// can bound the window to one adoption.
40pub 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        // A file no newer than the best so far cannot win, and the tag
76        // scan it would cost is the expensive part. The id breaks a tie,
77        // so the winner does not depend on the order the directory
78        // happens to list.
79        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
95/// The last tag the transcript at `path` carries, and the project its row
96/// names when it carries one.
97fn 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        // The newest mtime goes to whichever id the directory lists in
124        // the middle, where neither the first nor the last entry is: an
125        // implementation that answered by listing order instead of by
126        // time would name a different session here.
127        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    /// Everything that ran in this directory writes here, so the newest
141    /// file is often not the worker's: a subagent of its own, or a
142    /// session a person started by hand. The tag is what says whose
143    /// history this is.
144    #[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    /// The composed worktree directory is shared across a repository's
158    /// projects, so the label alone is not enough: another project's
159    /// tagged session must not be resumed. A tag row written before the
160    /// project field existed carries none and stays eligible.
161    #[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    /// Two sessions can carry the same timestamp: a restored or synced
189    /// tree keeps coarse mtimes, and a transcript being written now can
190    /// share a second with the one before it. The id decides between
191    /// them, so the answer does not depend on the order the directory
192    /// happens to list.
193    #[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        // claude leaves these beside the sessions, they are newer than
213        // the session they belong to, and they carry its tag.
214        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    /// The label's transcript directory, created.
237    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    /// The `.jsonl` session ids in `dir`, in the order the directory
247    /// lists them.
248    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    /// `<session_id>.jsonl` in `dir`, tagged for `label`, last written at
258    /// `modified` (unix seconds).
259    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    /// A session tagged for `label` AND the project that tagged it.
265    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    /// A session that ran in this directory and carries no worker tag.
279    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}