Fix stale thread-name resume lookups (#16646)

Addresses #15943

Problem: Name-based resume could stop on a newer session_index entry
whose rollout was never persisted, shadowing an older saved thread with
the same name.

Solution: Materialize rollouts before indexing thread names and make
name lookup skip unresolved entries until it finds a persisted rollout.
This commit is contained in:
Eric Traut authored and GitHub committed 2026-04-08 18:51:29 -07:00
1 parent 4dca906e19
commit 36586eafed
7 files changed
+256 -48

No files matched your search

+141 -3
View File
@@ -1,6 +1,11 @@
#![allow(warnings, clippy::all)]
use super::*;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -14,6 +19,34 @@ fn write_index(path: &Path, lines: &[SessionIndexEntry]) -> std::io::Result<()>
std::fs::write(path, out)
}
fn write_rollout_with_metadata(path: &Path, thread_id: ThreadId) -> std::io::Result<()> {
let timestamp = "2024-01-01T00-00-00Z".to_string();
let line = RolloutLine {
timestamp: timestamp.clone(),
item: RolloutItem::SessionMeta(SessionMetaLine {
meta: SessionMeta {
id: thread_id,
forked_from_id: None,
timestamp,
cwd: ".".into(),
originator: "test_originator".into(),
cli_version: "test_version".into(),
source: SessionSource::Cli,
agent_path: None,
agent_nickname: None,
agent_role: None,
model_provider: Some("test-provider".into()),
base_instructions: None,
dynamic_tools: None,
memory_mode: None,
},
git: None,
}),
};
let body = serde_json::to_string(&line).map_err(std::io::Error::other)?;
std::fs::write(path, format!("{body}\n"))
}
#[test]
fn find_thread_id_by_name_prefers_latest_entry() -> std::io::Result<()> {
let temp = TempDir::new()?;
@@ -34,11 +67,116 @@ fn find_thread_id_by_name_prefers_latest_entry() -> std::io::Result<()> {
];
write_index(&path, &lines)?;
let found = scan_index_from_end_by_name(&path, "same")?;
let found = scan_index_from_end(&path, |entry| entry.thread_name == "same")?;
assert_eq!(found.map(|entry| entry.id), Some(id2));
Ok(())
}
#[tokio::test]
async fn find_thread_path_by_name_str_skips_newest_entry_without_rollout() -> std::io::Result<()> {
// A newer unsaved name entry should not shadow an older persisted rollout with the same name.
let temp = TempDir::new()?;
let path = session_index_path(temp.path());
let saved_id = ThreadId::new();
let unsaved_id = ThreadId::new();
let saved_rollout_path = temp
.path()
.join("sessions/2024/01/01")
.join(format!("rollout-2024-01-01T00-00-00-{saved_id}.jsonl"));
std::fs::create_dir_all(saved_rollout_path.parent().expect("rollout parent"))?;
write_rollout_with_metadata(&saved_rollout_path, saved_id)?;
let lines = vec![
SessionIndexEntry {
id: saved_id,
thread_name: "same".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
SessionIndexEntry {
id: unsaved_id,
thread_name: "same".to_string(),
updated_at: "2024-01-02T00:00:00Z".to_string(),
},
];
write_index(&path, &lines)?;
let found = find_thread_path_by_name_str(temp.path(), "same").await?;
assert_eq!(found, Some(saved_rollout_path));
Ok(())
}
#[tokio::test]
async fn find_thread_path_by_name_str_skips_partial_rollout() -> std::io::Result<()> {
let temp = TempDir::new()?;
let path = session_index_path(temp.path());
let saved_id = ThreadId::new();
let partial_id = ThreadId::new();
let rollout_dir = temp.path().join("sessions/2024/01/01");
let saved_rollout_path =
rollout_dir.join(format!("rollout-2024-01-01T00-00-00-{saved_id}.jsonl"));
let partial_rollout_path =
rollout_dir.join(format!("rollout-2024-01-01T00-00-01-{partial_id}.jsonl"));
std::fs::create_dir_all(&rollout_dir)?;
write_rollout_with_metadata(&saved_rollout_path, saved_id)?;
std::fs::write(&partial_rollout_path, "")?;
let lines = vec![
SessionIndexEntry {
id: saved_id,
thread_name: "same".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
SessionIndexEntry {
id: partial_id,
thread_name: "same".to_string(),
updated_at: "2024-01-02T00:00:00Z".to_string(),
},
];
write_index(&path, &lines)?;
let found = find_thread_path_by_name_str(temp.path(), "same").await?;
assert_eq!(found, Some(saved_rollout_path));
Ok(())
}
#[tokio::test]
async fn find_thread_path_by_name_str_ignores_historical_name_after_rename() -> std::io::Result<()>
{
let temp = TempDir::new()?;
let path = session_index_path(temp.path());
let renamed_id = ThreadId::new();
let current_id = ThreadId::new();
let current_rollout_path = temp
.path()
.join("sessions/2024/01/01")
.join(format!("rollout-2024-01-01T00-00-00-{current_id}.jsonl"));
std::fs::create_dir_all(current_rollout_path.parent().expect("rollout parent"))?;
write_rollout_with_metadata(&current_rollout_path, current_id)?;
let lines = vec![
SessionIndexEntry {
id: renamed_id,
thread_name: "same".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
SessionIndexEntry {
id: current_id,
thread_name: "same".to_string(),
updated_at: "2024-01-02T00:00:00Z".to_string(),
},
SessionIndexEntry {
id: renamed_id,
thread_name: "different".to_string(),
updated_at: "2024-01-03T00:00:00Z".to_string(),
},
];
write_index(&path, &lines)?;
let found = find_thread_path_by_name_str(temp.path(), "same").await?;
assert_eq!(found, Some(current_rollout_path));
Ok(())
}
#[test]
fn find_thread_name_by_id_prefers_latest_entry() -> std::io::Result<()> {
let temp = TempDir::new()?;
@@ -78,7 +216,7 @@ fn scan_index_returns_none_when_entry_missing() -> std::io::Result<()> {
}];
write_index(&path, &lines)?;
let missing_name = scan_index_from_end_by_name(&path, "missing")?;
let missing_name = scan_index_from_end(&path, |entry| entry.thread_name == "missing")?;
assert_eq!(missing_name, None);
let missing_id = scan_index_from_end_by_id(&path, &ThreadId::new())?;
@@ -157,7 +295,7 @@ fn scan_index_finds_latest_match_among_mixed_entries() -> std::io::Result<()> {
];
write_index(&path, &lines)?;
let found_by_name = scan_index_from_end_by_name(&path, "target")?;
let found_by_name = scan_index_from_end(&path, |entry| entry.thread_name == "target")?;
assert_eq!(found_by_name, Some(expected.clone()));
let found_by_id = scan_index_from_end_by_id(&path, &id_target)?;