session: trace resolve/attach/compact points for #2707 wrong-session theory

This commit is contained in:
damocles 2026-07-26 20:52:40 +02:00 committed by mara
commit 1bbc09e9dd
4 changed files with 72 additions and 12 deletions

1
Cargo.lock generated
View file

@ -1646,6 +1646,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]

View file

@ -11,6 +11,7 @@ serde = { workspace = true }
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile = "3"

View file

@ -69,7 +69,14 @@ impl<P: CompactionPolicy> InfiniteSession<P> {
// Resolve resume-vs-create once, up front, so `created` reflects the
// session state at the START of the run: the reactive-retry path can't
// read it from the retry (the session exists by then).
let existed = self.store.find_by_title(&self.name).is_some();
let resolved = self.store.find_by_title(&self.name);
let existed = resolved.is_some();
tracing::info!(
title = %self.name,
existed,
path = resolved.as_deref().map(|p| p.display().to_string()).unwrap_or_default(),
"InfiniteSession::run: resolved session before attach"
);
let meter = TelemetrySink::new(sink);
let created = match self.attempt(config, prompt, &meter, existed).await {
Ok(created) => created,
@ -124,8 +131,19 @@ impl<P: CompactionPolicy> InfiniteSession<P> {
///
/// Propagates any error other than `SessionNotFound` from the compact run.
pub async fn compact(&self, config: &Config, sink: &impl Sink) -> Result<()> {
match Claude::run(config, &Attach::Resume(self.name.clone()), "/compact", sink).await {
Err(Error::SessionNotFound) => Ok(()),
let resolved = self.store.find_by_title(&self.name);
tracing::info!(
title = %self.name,
path = resolved.as_deref().map(|p| p.display().to_string()).unwrap_or_default(),
"InfiniteSession::compact: attaching /compact to this session"
);
let result =
Claude::run(config, &Attach::Resume(self.name.clone()), "/compact", sink).await;
match result {
Err(Error::SessionNotFound) => {
tracing::info!(title = %self.name, "InfiniteSession::compact: session not found, no-op");
Ok(())
}
other => other,
}
}
@ -148,11 +166,20 @@ impl<P: CompactionPolicy> InfiniteSession<P> {
} else {
Attach::Create(self.name.clone())
};
tracing::info!(
title = %self.name,
mode = if existed { "resume" } else { "create" },
"InfiniteSession::attempt: attaching turn"
);
match Claude::run(config, &attach, prompt, sink).await {
Ok(()) => Ok(!existed),
// We thought it existed but the resume missed (raced an archive) —
// self-heal by creating.
Err(Error::SessionNotFound) if existed => {
tracing::warn!(
title = %self.name,
"InfiniteSession::attempt: resume missed (raced an archive?) — self-healing via create"
);
Claude::run(config, &Attach::Create(self.name.clone()), prompt, sink).await?;
Ok(true)
}

View file

@ -44,12 +44,21 @@ impl SessionStore {
/// Find the `<uuid>.jsonl` in the project dir whose `customTitle` equals
/// `title` (the value `--name` sets, stored in a `custom-title` event).
///
/// Reads each session file line by line and stops at the first match, so a
/// huge transcript isn't slurped into memory. Returns `None` if no session
/// carries the title or the project dir is absent. Non-`.jsonl` files
/// (including anything already archived to `*.jsonl.archived`) are skipped.
/// Reads each session file line by line, so a huge transcript isn't
/// slurped into memory. Returns `None` if no session carries the title or
/// the project dir is absent. Non-`.jsonl` files (including anything
/// already archived to `*.jsonl.archived`) are skipped.
///
/// Unlike the old first-match-wins scan, this keeps going past the first
/// hit so a *second* same-titled file — which would otherwise resolve to
/// whichever one `read_dir` happens to yield first, i.e. filesystem-order
/// luck — logs a `tracing::warn!` instead of silently picking one. The
/// first match found is still what's returned (unchanged resolution
/// behaviour); this is a diagnostic for the "wrong session resumed"
/// report, not a fix.
#[must_use]
pub fn find_by_title(&self, title: &str) -> Option<PathBuf> {
let mut found: Option<PathBuf> = None;
for entry in std::fs::read_dir(self.project_dir()).ok()?.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
@ -58,15 +67,27 @@ impl SessionStore {
let Ok(file) = std::fs::File::open(&path) else {
continue;
};
if std::io::BufReader::new(file)
let matches = std::io::BufReader::new(file)
.lines()
.map_while(std::result::Result::ok)
.any(|line| line_sets_title(&line, title))
{
return Some(path);
.any(|line| line_sets_title(&line, title));
if !matches {
continue;
}
match &found {
None => found = Some(path),
Some(first) => {
tracing::warn!(
title,
first = %first.display(),
also = %path.display(),
"find_by_title: multiple session files share this title — \
resuming the first one found (readdir order, not deterministic)"
);
}
}
}
None
found
}
/// Archive the session titled `title` by renaming its backing file
@ -83,12 +104,22 @@ impl SessionStore {
/// [`Error::Io`] if the rename fails.
pub fn archive_by_title(&self, title: &str) -> Result<Option<PathBuf>> {
let Some(path) = self.find_by_title(title) else {
tracing::info!(
title,
"archive_by_title: no session file found for this title"
);
return Ok(None);
};
let mut target = path.clone().into_os_string();
target.push(".archived");
let target = PathBuf::from(target);
std::fs::rename(&path, &target).map_err(Error::Io)?;
tracing::info!(
title,
from = %path.display(),
to = %target.display(),
"archive_by_title: renamed session file"
);
Ok(Some(target))
}
}