Offer a session once when two project folders hold it, and never crash on a repeat
Resuming a Claude Code session from a different working directory makes the CLI write a second transcript with the same id under that directory's project folder. This machine has one: 160 KB under `-home-bob-repos-tdep-survey` and a 614-byte stub under `-home-bob-repos-tdep`. Everything downstream addresses a session by id -- `--resume` takes it, the delete glob resolves it, the in-flight registry is keyed on it -- so two rows sharing an id are two rows no operation can tell apart, and the phone keys its list on it, so scrolling to them closed the app on Compose's duplicate-key throw. The listing now keeps the copy with the most in it. Size rather than recency, because the stub is often the newer of the two, and picking it describes the session by the wrong size, the wrong cwd and the wrong title. Deleting removes every copy rather than stopping at the first, which had left the row to come back on the next listing after a delete that reported success. The phone's half is `uniqueItems`: every list keyed on a server-chosen id goes through it, since none of them could rule the repeat out locally and a data problem must not be able to close the app. Verified both ways against the real duplicate -- the unguarded build reproduces the reported stack on the same id, the guarded one scrolls the whole list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8257030280
commit
7a8811aab3
7 files changed
+124
-11
No files matched your search
@@ -257,6 +257,30 @@ fn parse_listing(found: &str) -> Result<Vec<Importable>> {
|
||||
(false, false) => InUse::Unknown,
|
||||
};
|
||||
}
|
||||
// One row per session id, because the id is what everything downstream
|
||||
// addresses: `--resume` takes it, deleting globs for it, and the
|
||||
// in-flight registry is keyed on it. So two rows sharing an id are two
|
||||
// rows that no operation can tell apart -- and the phone keys its list
|
||||
// on it too, which turned this into a crash rather than a confusion.
|
||||
//
|
||||
// It is a real state of the machine, not corruption: resuming a session
|
||||
// from a different working directory makes the CLI write a second file
|
||||
// under that directory's project folder with the same id. One of the two
|
||||
// is then usually a stub of a few hundred bytes and the other is the
|
||||
// conversation somebody means.
|
||||
//
|
||||
// So the copy with the most in it wins, and the row's `cwd` comes from
|
||||
// that same copy -- which is the directory `--resume` will find it under.
|
||||
// Ties go to the more recent, and the *stub* is often the more recent, so
|
||||
// the size has to be the first key rather than the tie-break.
|
||||
sessions.sort_by(|a, b| {
|
||||
b.lines
|
||||
.cmp(&a.lines)
|
||||
.then_with(|| b.modified.total_cmp(&a.modified))
|
||||
});
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
sessions.retain(|session| seen.insert(session.id.clone()));
|
||||
|
||||
// Most recent first, and only that. Naming was tried as the first key
|
||||
// and is a worse list: it buries what somebody was just doing under
|
||||
// everything they ever named, and the reason to open this screen is
|
||||
@@ -672,12 +696,15 @@ pub async fn delete(transport: &Transport, id: &str) -> Result<()> {
|
||||
// `context_of` below already resolved an id the cheap way; this is the
|
||||
// same lookup, and the two now agree.
|
||||
ensure!(is_session_id(id), "not a Claude Code session id: {id}");
|
||||
// Every copy, not the first. The same id can name a file under two
|
||||
// project directories -- see the de-duplication in `parse_listing` --
|
||||
// and stopping at the first left the other behind, so the row came back
|
||||
// on the next listing after a delete that had reported success.
|
||||
let script = r#"
|
||||
for f in "$HOME"/.claude/projects/*/"$1".jsonl; do
|
||||
[ -f "$f" ] || continue
|
||||
rm -f "$f" || exit 1
|
||||
printf '%s\n' "$f"
|
||||
exit 0
|
||||
done
|
||||
"#;
|
||||
let launch = Launch::new(
|
||||
@@ -852,6 +879,37 @@ pub async fn replay_after(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One session id, two files, one row.
|
||||
///
|
||||
/// Resuming a session from a different working directory makes the CLI
|
||||
/// write a second file with the same id under that directory's project
|
||||
/// folder, so this is an ordinary state of a machine rather than a
|
||||
/// corrupt one. Everything downstream addresses a session by id, and
|
||||
/// the phone keys its list on it, so two rows sharing one was a crash.
|
||||
///
|
||||
/// The stub is deliberately the *newer* of the two here, because that
|
||||
/// is how the real case looked: ordering by recency alone picks the
|
||||
/// near-empty copy and describes the session by the wrong cwd.
|
||||
#[test]
|
||||
fn a_session_recorded_under_two_projects_is_offered_once() {
|
||||
let id = "3114dee1-2f95-4de0-9c04-3d6fcc594afe";
|
||||
let said = r#"{"cwd":"/home/bob/repos/survey","message":{"role":"user","content":"the real conversation"}}"#;
|
||||
let stub = r#"{"cwd":"/home/bob/repos/elsewhere","message":{"role":"user","content":"resumed here once"}}"#;
|
||||
let listing = format!(
|
||||
"LIVEKNOWN\n\
|
||||
1000.0\t412\t160638\t\t/home/bob/.claude/projects/-home-bob-repos-survey/{id}.jsonl\t{said}\n\
|
||||
2000.0\t4\t614\t\t/home/bob/.claude/projects/-home-bob-repos-elsewhere/{id}.jsonl\t{stub}\n"
|
||||
);
|
||||
|
||||
let rows = parse_listing(&listing).expect("parse");
|
||||
|
||||
assert_eq!(rows.len(), 1, "one id is one row: {rows:#?}");
|
||||
assert_eq!(rows[0].lines, 412, "the conversation, not the stub");
|
||||
// The cwd has to come from the copy that was kept, because that is
|
||||
// the directory `--resume` will find those 412 lines under.
|
||||
assert_eq!(rows[0].cwd, "/home/bob/repos/survey");
|
||||
}
|
||||
|
||||
/// The guard on the only thing this module ever puts in a glob.
|
||||
///
|
||||
/// Worth a test of its own because what it protects is a `rm`: `delete`
|
||||
|
||||
Reference in new issue
Block a user