From 1bbc09e9dd4eb07347821a7a86a71b508f7d836e Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 26 Jul 2026 20:52:40 +0200 Subject: [PATCH] session: trace resolve/attach/compact points for #2707 wrong-session theory --- Cargo.lock | 1 + hive-claude/Cargo.toml | 1 + hive-claude/src/session.rs | 33 ++++++++++++++++++++++--- hive-claude/src/store.rs | 49 +++++++++++++++++++++++++++++++------- 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 24803d40..f942f9ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1646,6 +1646,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tracing", ] [[package]] diff --git a/hive-claude/Cargo.toml b/hive-claude/Cargo.toml index f9cd1756..a0893e8e 100644 --- a/hive-claude/Cargo.toml +++ b/hive-claude/Cargo.toml @@ -11,6 +11,7 @@ serde = { workspace = true } serde_json.workspace = true thiserror.workspace = true tokio.workspace = true +tracing.workspace = true [dev-dependencies] tempfile = "3" diff --git a/hive-claude/src/session.rs b/hive-claude/src/session.rs index 03ea5e2b..b11b2827 100644 --- a/hive-claude/src/session.rs +++ b/hive-claude/src/session.rs @@ -69,7 +69,14 @@ impl InfiniteSession

{ // 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 InfiniteSession

{ /// /// 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 InfiniteSession

{ } 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) } diff --git a/hive-claude/src/store.rs b/hive-claude/src/store.rs index 11e80545..9b775342 100644 --- a/hive-claude/src/store.rs +++ b/hive-claude/src/store.rs @@ -44,12 +44,21 @@ impl SessionStore { /// Find the `.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 { + let mut found: Option = 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> { 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)) } }