Skip to content

Commit e37eda3

Browse files
committed
fix(claude): group pruned files by the session IDs they record
Workflow subagent transcripts can live under one session directory and record another session's ID, so path-based groups let a stale parent be dropped while its in-window replay survived. Files now join a union-find over their path-derived session and the first `sessionId` they record, which is the identity the loaders deduplicate on. This also covers legacy flat `agent-*.jsonl` files and copies in another config root, and the probe parses JSON lines so whitespace or long leading lines no longer hide the ID.
1 parent 5a3b8fb commit e37eda3

2 files changed

Lines changed: 208 additions & 58 deletions

File tree

‎rust/adapters/claude/src/paths.rs‎

Lines changed: 147 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
use std::{
22
env, fs,
3+
io::{BufRead as _, BufReader},
34
path::{Path, PathBuf},
45
time::UNIX_EPOCH,
56
};
67

78
use jiff::tz::TimeZone as JiffTimeZone;
89
use memchr::memmem;
10+
use serde::Deserialize;
911

1012
use crate::{
11-
MILLIS_PER_DAY, Result, cli::SharedArgs, cli_error, date_range_bounds_ms, fast::FxHashSet,
12-
home, parse_tz, path_utils::expand_home_path,
13+
MILLIS_PER_DAY, Result,
14+
cli::SharedArgs,
15+
cli_error, date_range_bounds_ms,
16+
fast::{FxHashMap, FxHashSet},
17+
home, parse_tz,
18+
path_utils::expand_home_path,
1319
};
1420
use crate::{TimestampMs, parse_ts_timestamp};
1521
use ccusage_adapter_common::collect_usage_files;
@@ -90,17 +96,19 @@ pub(super) struct SinceFiles {
9096
/// window.
9197
///
9298
/// Claude session JSONL files are append-only, so a file last written before
93-
/// the window opened cannot hold an entry inside it. Files are kept or dropped
94-
/// per session, the main transcript together with its subagent transcripts
95-
/// and any copy in another config root, because deduplication matches
96-
/// sidechain replays against their parent messages within a session; dropping
97-
/// the parent file alone would let a replay count twice. Copies of a message
98-
/// in other sessions, such as resumed transcripts, keep the original
99-
/// timestamp, so they fall on the same side of the window as the original.
100-
/// Only the file list is narrowed; entries parsed from a
101-
/// surviving file are untouched. Every file is kept when `since` is unset or
102-
/// unparsable, when the window opens ahead of the current clock, or when a
103-
/// file's mtime cannot be read.
99+
/// the window opened cannot hold an entry inside it. Deduplication matches
100+
/// sidechain replays against their parent messages by session ID, which spans
101+
/// files (subagent and workflow transcripts, legacy flat `agent-*.jsonl`
102+
/// files, copies in another config root), so files are kept or dropped per
103+
/// connected group of session IDs: dropping a parent file alone would let its
104+
/// replay count twice. Copies of a message in other sessions, such as resumed
105+
/// transcripts, keep the original timestamp, so they fall on the same side of
106+
/// the window as the original.
107+
///
108+
/// Only the file list is narrowed; entries parsed from a surviving file are
109+
/// untouched. Every file is kept when `since` is unset or unparsable, when the
110+
/// window opens ahead of the current clock, or when a file's mtime cannot be
111+
/// read.
104112
///
105113
/// @param files Discovered usage files.
106114
/// @param shared Report arguments providing `since` and `timezone`.
@@ -127,60 +135,102 @@ pub(super) fn split_files_before_since(
127135
return keep_all(files);
128136
}
129137
let threshold = since_ms.saturating_sub(MTIME_PRUNE_MARGIN_MS);
130-
let live_sessions = files
131-
.iter()
132-
.filter(|file| file_modified_millis(file).is_none_or(|modified| modified >= threshold))
133-
.map(|file| session_group_key(file))
134-
.collect::<FxHashSet<String>>();
135-
let (kept, pruned) = files
136-
.into_iter()
137-
.partition(|file| live_sessions.contains(&session_group_key(file)));
138-
SinceFiles { kept, pruned }
139-
}
140138

141-
/// Identifies the session a usage file belongs to, relative to `projects/`
142-
/// so a session copied into another config root shares one key.
143-
///
144-
/// `<session>.jsonl` and everything under `<session>/` (such as `subagents/`)
145-
/// share a key. Legacy flat `agent-*.jsonl` sidechain transcripts sit beside
146-
/// their parent, so they key on the `sessionId` recorded inside them. Paths
147-
/// outside the `projects/<project>/<session>` layout key on themselves.
148-
fn session_group_key(path: &Path) -> String {
149-
let components = path
150-
.components()
151-
.map(|component| component.as_os_str().to_string_lossy())
139+
// Union every file's session IDs so files sharing any ID share a group.
140+
let mut sessions = SessionGroups::default();
141+
let file_groups = files
142+
.iter()
143+
.map(|file| {
144+
let (path_session, _) = extract_session_parts(file);
145+
let group = sessions.id(&path_session);
146+
if let Some(recorded) = recorded_session_id(file) {
147+
let recorded = sessions.id(&recorded);
148+
sessions.union(group, recorded);
149+
}
150+
group
151+
})
152152
.collect::<Vec<_>>();
153-
let relative = components
153+
let live_groups = files
154154
.iter()
155-
.position(|component| component == "projects")
156-
.map(|index| &components[index + 1..]);
157-
let Some([project, session, rest @ ..]) = relative else {
158-
return path.to_string_lossy().into_owned();
155+
.zip(&file_groups)
156+
.filter(|(file, _)| file_modified_millis(file).is_none_or(|modified| modified >= threshold))
157+
.map(|(_, &group)| sessions.root(group))
158+
.collect::<FxHashSet<_>>();
159+
160+
let mut split = SinceFiles {
161+
kept: Vec::new(),
162+
pruned: Vec::new(),
159163
};
160-
let session = session.strip_suffix(".jsonl").unwrap_or(session);
161-
if rest.is_empty()
162-
&& session.starts_with("agent-")
163-
&& let Some(parent_session) = recorded_session_id(path)
164-
{
165-
return format!("{project}/{parent_session}");
164+
for (file, group) in files.into_iter().zip(file_groups) {
165+
if live_groups.contains(&sessions.root(group)) {
166+
split.kept.push(file);
167+
} else {
168+
split.pruned.push(file);
169+
}
166170
}
167-
format!("{project}/{session}")
171+
split
168172
}
169173

170-
/// Reads the first `sessionId` recorded in a transcript without parsing it.
174+
/// Union-find over session IDs.
175+
#[derive(Default)]
176+
struct SessionGroups {
177+
ids: FxHashMap<String, usize>,
178+
parents: Vec<usize>,
179+
}
180+
181+
impl SessionGroups {
182+
fn id(&mut self, session: &str) -> usize {
183+
if let Some(&id) = self.ids.get(session) {
184+
return id;
185+
}
186+
let id = self.parents.len();
187+
self.parents.push(id);
188+
self.ids.insert(session.to_string(), id);
189+
id
190+
}
191+
192+
fn root(&mut self, mut id: usize) -> usize {
193+
while self.parents[id] != id {
194+
// Path halving: point each visited node at its grandparent.
195+
self.parents[id] = self.parents[self.parents[id]];
196+
id = self.parents[id];
197+
}
198+
id
199+
}
200+
201+
fn union(&mut self, left: usize, right: usize) {
202+
let (left, right) = (self.root(left), self.root(right));
203+
self.parents[left] = right;
204+
}
205+
}
206+
207+
/// Returns the first `sessionId` a transcript records, which is the session
208+
/// the loaders attribute its entries to. Transcripts record a single session,
209+
/// so reading stops at the first line that names one.
171210
fn recorded_session_id(path: &Path) -> Option<String> {
172-
use std::io::Read as _;
211+
#[derive(Deserialize)]
212+
struct SessionIdProbe {
213+
#[serde(rename = "sessionId")]
214+
session_id: Option<String>,
215+
}
173216

174-
const MARKER: &[u8] = br#""sessionId":""#;
175-
let mut head = Vec::new();
176-
fs::File::open(path)
177-
.ok()?
178-
.take(64 * 1024)
179-
.read_to_end(&mut head)
180-
.ok()?;
181-
let start = memmem::find(&head, MARKER)? + MARKER.len();
182-
let end = memchr::memchr(b'"', &head[start..])? + start;
183-
String::from_utf8(head[start..end].to_vec()).ok()
217+
let mut reader = BufReader::new(fs::File::open(path).ok()?);
218+
let mut line = Vec::new();
219+
loop {
220+
line.clear();
221+
if reader.read_until(b'\n', &mut line).ok()? == 0 {
222+
return None;
223+
}
224+
if memmem::find(&line, b"\"sessionId\"").is_none() {
225+
continue;
226+
}
227+
if let Ok(SessionIdProbe {
228+
session_id: Some(session_id),
229+
}) = serde_json::from_slice(&line)
230+
{
231+
return Some(session_id);
232+
}
233+
}
184234
}
185235

186236
fn file_modified_millis(path: &Path) -> Option<i64> {
@@ -463,4 +513,43 @@ mod tests {
463513
assert_eq!(split.kept, [live_agent, live, other_root_copy]);
464514
assert_eq!(split.pruned, [stale_agent]);
465515
}
516+
517+
#[test]
518+
fn keeps_sessions_linked_by_a_recorded_session_id_in_another_directory() {
519+
let fixture = fs_fixture!({
520+
"projects/p/session-y.jsonl": r#"{"sessionId":"session-y"}"#,
521+
"projects/p/session-x/subagents/workflows/wf_1/agent-a.jsonl": "{\"type\":\"summary\"}\n{\"sessionId\" : \"session-y\"}",
522+
});
523+
let root = fixture.root();
524+
let parent = root.join("projects/p/session-y.jsonl");
525+
let workflow_agent =
526+
root.join("projects/p/session-x/subagents/workflows/wf_1/agent-a.jsonl");
527+
set_file_modified(&parent, ts("2026-09-01T12:00:00Z"));
528+
set_file_modified(&workflow_agent, ts("2026-09-21T12:00:00Z"));
529+
530+
let split = split_files_before_since(
531+
vec![parent.clone(), workflow_agent.clone()],
532+
&shared_since(Some("20260920")),
533+
ts("2026-09-24T00:00:00Z"),
534+
);
535+
536+
assert_eq!(split.kept, [parent, workflow_agent]);
537+
assert!(split.pruned.is_empty());
538+
}
539+
540+
#[test]
541+
fn reads_the_recorded_session_id_past_long_leading_lines() {
542+
let padding = format!(
543+
r#"{{"type":"summary","text":"{}"}}"#,
544+
"x".repeat(100 * 1024)
545+
);
546+
let fixture = fs_fixture!({
547+
"projects/p/agent-1.jsonl": format!("{padding}\n{{\"sessionId\": \"parent\"}}"),
548+
});
549+
550+
assert_eq!(
551+
super::recorded_session_id(&fixture.root().join("projects/p/agent-1.jsonl")).as_deref(),
552+
Some("parent")
553+
);
554+
}
466555
}

‎rust/crates/ccusage/tests/claude_cli.rs‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,3 +524,64 @@ fn statusline_keeps_the_current_block_before_future_dated_entries() {
524524
);
525525
assert!(output.contains("$1.00 block"), "{output}");
526526
}
527+
528+
#[test]
529+
fn bounded_reports_dedupe_replays_filed_under_another_session_directory() {
530+
let fixture = Fixture::new();
531+
let parent = fixture.write_file(
532+
"projects/project-a/session-a.jsonl",
533+
usage_line(
534+
"2026-09-01T12:00:00.000Z",
535+
"session-a",
536+
"msg-parent",
537+
"req-parent",
538+
1.0,
539+
),
540+
);
541+
// Workflow transcripts can live under one session and record another.
542+
let workflow = fixture.write_file(
543+
"projects/project-a/session-b/subagents/workflows/wf_1/agent-a.jsonl",
544+
[
545+
sidechain_line(
546+
"2026-09-11T12:00:00.000Z",
547+
"session-a",
548+
"msg-parent",
549+
"req-replay",
550+
2.0,
551+
),
552+
sidechain_line(
553+
"2026-09-11T12:05:00.000Z",
554+
"session-a",
555+
"msg-answer",
556+
"req-answer",
557+
1.0,
558+
),
559+
]
560+
.join("\n"),
561+
);
562+
// 2026-09-01T12:00:00Z and 2026-09-11T12:05:00Z
563+
set_file_modified(&parent, 1_788_264_000);
564+
set_file_modified(&workflow, 1_789_128_300);
565+
566+
let output = assert_output_ignores_mtimes(
567+
&fixture,
568+
&[parent, workflow],
569+
FRESH_MTIME,
570+
&[
571+
"claude",
572+
"daily",
573+
"--since",
574+
"20260910",
575+
"--timezone",
576+
"UTC",
577+
"--mode",
578+
"display",
579+
"--offline",
580+
"--json",
581+
],
582+
None,
583+
str::to_string,
584+
);
585+
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
586+
assert_eq!(json["totals"]["totalCost"], 1.0, "{output}");
587+
}

0 commit comments

Comments
 (0)