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", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]

View file

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

View file

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

View file

@ -44,12 +44,21 @@ impl SessionStore {
/// Find the `<uuid>.jsonl` in the project dir whose `customTitle` equals /// Find the `<uuid>.jsonl` in the project dir whose `customTitle` equals
/// `title` (the value `--name` sets, stored in a `custom-title` event). /// `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 /// Reads each session file line by line, so a huge transcript isn't
/// huge transcript isn't slurped into memory. Returns `None` if no session /// slurped into memory. Returns `None` if no session carries the title or
/// carries the title or the project dir is absent. Non-`.jsonl` files /// the project dir is absent. Non-`.jsonl` files (including anything
/// (including anything already archived to `*.jsonl.archived`) are skipped. /// 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] #[must_use]
pub fn find_by_title(&self, title: &str) -> Option<PathBuf> { 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() { for entry in std::fs::read_dir(self.project_dir()).ok()?.flatten() {
let path = entry.path(); let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { 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 { let Ok(file) = std::fs::File::open(&path) else {
continue; continue;
}; };
if std::io::BufReader::new(file) let matches = std::io::BufReader::new(file)
.lines() .lines()
.map_while(std::result::Result::ok) .map_while(std::result::Result::ok)
.any(|line| line_sets_title(&line, title)) .any(|line| line_sets_title(&line, title));
{ if !matches {
return Some(path); 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 /// Archive the session titled `title` by renaming its backing file
@ -83,12 +104,22 @@ impl SessionStore {
/// [`Error::Io`] if the rename fails. /// [`Error::Io`] if the rename fails.
pub fn archive_by_title(&self, title: &str) -> Result<Option<PathBuf>> { pub fn archive_by_title(&self, title: &str) -> Result<Option<PathBuf>> {
let Some(path) = self.find_by_title(title) else { 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); return Ok(None);
}; };
let mut target = path.clone().into_os_string(); let mut target = path.clone().into_os_string();
target.push(".archived"); target.push(".archived");
let target = PathBuf::from(target); let target = PathBuf::from(target);
std::fs::rename(&path, &target).map_err(Error::Io)?; 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)) Ok(Some(target))
} }
} }