mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Persist session IDs across thread resume (#29327)
## Summary
A cold-resumed subagent kept its durable thread ID but could receive a
new session ID, splitting one agent tree across multiple sessions after
a restart.
Persist the root session ID in every rollout `SessionMeta`, carry it
through thread creation, and restore it before initializing the resumed
`Session` and `AgentControl`.
## Behavior
For a nested agent tree:
```text
root session R
parent thread P
child thread C
```
The child rollout stores:
```text
session_id: R
parent_thread_id: P
id: C
```
After a cold resume, the child still belongs to root session `R` while
its immediate parent remains `P`. The integration coverage uses distinct
values for all three IDs so it catches restoring the session from
`parent_thread_id`.
## Legacy rollouts
Previous rollouts have `id` but no `session_id`. `SessionMetaLine`
deserialization treats a missing `session_id` as `id`, keeping those
files readable, listable, and resumable. When a legacy subagent is
resumed through its root, that synthesized child ID no longer overrides
the inherited root-scoped `AgentControl`. New rollouts always persist
the explicit root session ID.
This commit is contained in:
@@ -456,6 +456,7 @@ fn write_rollout(path: &std::path::Path, thread_id: ThreadId, message: &str) ->
|
||||
fs::create_dir_all(parent)?;
|
||||
let session_meta_line = SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
session_id: thread_id.into(),
|
||||
id: thread_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
|
||||
@@ -33,6 +33,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() {
|
||||
.join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl"));
|
||||
|
||||
let session_meta = SessionMeta {
|
||||
session_id: id.into(),
|
||||
id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
@@ -88,6 +89,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() {
|
||||
.join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl"));
|
||||
|
||||
let session_meta = SessionMeta {
|
||||
session_id: id.into(),
|
||||
id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
@@ -355,6 +357,7 @@ fn write_rollout_in_sessions_with_cwd(
|
||||
std::fs::create_dir_all(sessions_dir.as_path()).expect("create sessions dir");
|
||||
let path = sessions_dir.join(format!("rollout-{filename_ts}-{thread_uuid}.jsonl"));
|
||||
let session_meta = SessionMeta {
|
||||
session_id: id.into(),
|
||||
id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use chrono::SecondsFormat;
|
||||
use codex_protocol::SessionId;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
@@ -81,10 +82,11 @@ pub struct RolloutRecorder {
|
||||
#[derive(Clone)]
|
||||
pub enum RolloutRecorderParams {
|
||||
Create {
|
||||
session_id: SessionId,
|
||||
conversation_id: ThreadId,
|
||||
forked_from_id: Option<ThreadId>,
|
||||
parent_thread_id: Option<ThreadId>,
|
||||
source: SessionSource,
|
||||
source: Box<SessionSource>,
|
||||
thread_source: Option<ThreadSource>,
|
||||
base_instructions: BaseInstructions,
|
||||
dynamic_tools: Vec<DynamicToolSpec>,
|
||||
@@ -167,10 +169,11 @@ impl RolloutRecorderParams {
|
||||
dynamic_tools: Vec<DynamicToolSpec>,
|
||||
) -> Self {
|
||||
Self::Create {
|
||||
session_id: conversation_id.into(),
|
||||
conversation_id,
|
||||
forked_from_id,
|
||||
parent_thread_id,
|
||||
source,
|
||||
source: Box::new(source),
|
||||
thread_source,
|
||||
base_instructions,
|
||||
dynamic_tools,
|
||||
@@ -178,6 +181,13 @@ impl RolloutRecorderParams {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_session_id(mut self, session_id: SessionId) -> Self {
|
||||
if let Self::Create { session_id: id, .. } = &mut self {
|
||||
*id = session_id;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_multi_agent_version(
|
||||
mut self,
|
||||
multi_agent_version: Option<MultiAgentVersion>,
|
||||
@@ -696,6 +706,7 @@ impl RolloutRecorder {
|
||||
) -> std::io::Result<Self> {
|
||||
let (file, deferred_log_file_info, rollout_path, meta) = match params {
|
||||
RolloutRecorderParams::Create {
|
||||
session_id,
|
||||
conversation_id,
|
||||
forked_from_id,
|
||||
parent_thread_id,
|
||||
@@ -707,7 +718,7 @@ impl RolloutRecorder {
|
||||
} => {
|
||||
let log_file_info = precompute_log_file_info(config, conversation_id)?;
|
||||
let path = log_file_info.path.clone();
|
||||
let session_id = log_file_info.conversation_id;
|
||||
let thread_id = log_file_info.conversation_id;
|
||||
let started_at = log_file_info.timestamp;
|
||||
|
||||
let timestamp_format: &[FormatItem] = format_description!(
|
||||
@@ -719,7 +730,8 @@ impl RolloutRecorder {
|
||||
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
|
||||
|
||||
let session_meta = SessionMeta {
|
||||
id: session_id,
|
||||
session_id,
|
||||
id: thread_id,
|
||||
forked_from_id,
|
||||
parent_thread_id,
|
||||
timestamp,
|
||||
@@ -729,7 +741,7 @@ impl RolloutRecorder {
|
||||
agent_nickname: source.get_nickname(),
|
||||
agent_role: source.get_agent_role(),
|
||||
agent_path: source.get_agent_path().map(Into::into),
|
||||
source,
|
||||
source: *source,
|
||||
thread_source,
|
||||
model_provider: Some(config.model_provider_id().to_string()),
|
||||
base_instructions: Some(base_instructions),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use super::*;
|
||||
use crate::config::RolloutConfig;
|
||||
use chrono::TimeZone;
|
||||
use codex_protocol::SessionId;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
@@ -45,6 +46,7 @@ fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result<Path
|
||||
"timestamp": ts,
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"session_id": uuid,
|
||||
"id": uuid,
|
||||
"timestamp": ts,
|
||||
"cwd": ".",
|
||||
@@ -83,6 +85,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> {
|
||||
|
||||
let session_meta_line = SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
session_id: thread_id.into(),
|
||||
id: thread_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
@@ -145,7 +148,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_rollout_items_skips_legacy_ghost_snapshot_lines() -> std::io::Result<()> {
|
||||
async fn load_rollout_items_defaults_legacy_session_id() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let rollout_path = home.path().join("rollout.jsonl");
|
||||
let mut file = File::create(&rollout_path)?;
|
||||
@@ -210,7 +213,10 @@ async fn load_rollout_items_skips_legacy_ghost_snapshot_lines() -> std::io::Resu
|
||||
assert_eq!(loaded_thread_id, Some(thread_id));
|
||||
assert_eq!(parse_errors, 0);
|
||||
assert_eq!(items.len(), 2);
|
||||
assert!(matches!(items[0], RolloutItem::SessionMeta(_)));
|
||||
let RolloutItem::SessionMeta(session_meta) = &items[0] else {
|
||||
panic!("expected session metadata");
|
||||
};
|
||||
assert_eq!(session_meta.meta.session_id, SessionId::from(thread_id));
|
||||
assert!(matches!(
|
||||
items[1],
|
||||
RolloutItem::ResponseItem(ResponseItem::Message { .. })
|
||||
@@ -234,6 +240,7 @@ async fn load_rollout_items_preserves_legacy_guardian_assessment_lines() -> std:
|
||||
"timestamp": ts,
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"session_id": thread_id,
|
||||
"id": thread_id,
|
||||
"timestamp": ts,
|
||||
"cwd": ".",
|
||||
@@ -297,6 +304,7 @@ async fn load_rollout_items_filters_legacy_ghost_snapshots_from_compaction_histo
|
||||
"timestamp": ts,
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"session_id": thread_id,
|
||||
"id": thread_id,
|
||||
"timestamp": ts,
|
||||
"cwd": ".",
|
||||
@@ -365,6 +373,7 @@ async fn load_rollout_items_filters_legacy_ghost_snapshots_from_compaction_histo
|
||||
async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let session_id = SessionId::default();
|
||||
let thread_id = ThreadId::new();
|
||||
let recorder = RolloutRecorder::new(
|
||||
&config,
|
||||
@@ -376,7 +385,8 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result<
|
||||
/*thread_source*/ None,
|
||||
BaseInstructions::default(),
|
||||
Vec::new(),
|
||||
),
|
||||
)
|
||||
.with_session_id(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -421,10 +431,12 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result<
|
||||
assert!(rollout_path.exists(), "rollout file should be materialized");
|
||||
|
||||
let text = std::fs::read_to_string(&rollout_path)?;
|
||||
assert!(
|
||||
text.contains("\"type\":\"session_meta\""),
|
||||
"expected session metadata in rollout"
|
||||
);
|
||||
let first_line = text.lines().next().expect("session metadata line");
|
||||
let session_meta: RolloutLine = serde_json::from_str(first_line)?;
|
||||
let RolloutItem::SessionMeta(session_meta) = session_meta.item else {
|
||||
panic!("expected session metadata in rollout");
|
||||
};
|
||||
assert_eq!(session_meta.meta.session_id, session_id);
|
||||
let buffered_idx = text
|
||||
.find("buffered-event")
|
||||
.expect("buffered event in rollout");
|
||||
@@ -732,6 +744,7 @@ async fn list_threads_state_db_only_skips_jsonl_repair_scan() -> std::io::Result
|
||||
"timestamp": ts,
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"session_id": uuid,
|
||||
"id": uuid,
|
||||
"timestamp": ts,
|
||||
"cwd": home.path().display().to_string(),
|
||||
|
||||
@@ -25,6 +25,7 @@ fn write_rollout_with_metadata(path: &Path, thread_id: ThreadId) -> std::io::Res
|
||||
timestamp: timestamp.clone(),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
session_id: thread_id.into(),
|
||||
id: thread_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
|
||||
@@ -158,6 +158,7 @@ fn write_rollout_with_user_message(
|
||||
timestamp: "2026-06-01T14:26:25Z".to_string(),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
session_id: thread_id.into(),
|
||||
id: thread_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
|
||||
@@ -297,6 +297,7 @@ fn write_session_file_with_provider(
|
||||
let mut file = File::create(file_path)?;
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"session_id": uuid,
|
||||
"id": uuid,
|
||||
"timestamp": ts_str,
|
||||
"cwd": ".",
|
||||
@@ -370,6 +371,7 @@ fn write_goal_started_session_file(
|
||||
"timestamp": ts_str,
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"session_id": uuid,
|
||||
"id": uuid,
|
||||
"timestamp": ts_str,
|
||||
"cwd": ".",
|
||||
@@ -451,6 +453,7 @@ fn write_session_file_with_delayed_user_event(
|
||||
Uuid::from_u128(100 + i as u128)
|
||||
};
|
||||
let payload = serde_json::json!({
|
||||
"session_id": uuid,
|
||||
"id": id,
|
||||
"timestamp": ts_str,
|
||||
"cwd": ".",
|
||||
@@ -483,8 +486,9 @@ fn write_session_file_with_meta_payload(
|
||||
root: &Path,
|
||||
ts_str: &str,
|
||||
uuid: Uuid,
|
||||
payload: serde_json::Value,
|
||||
mut payload: serde_json::Value,
|
||||
) -> std::io::Result<()> {
|
||||
payload["session_id"] = serde_json::json!(uuid);
|
||||
let format: &[FormatItem] =
|
||||
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
|
||||
let dt = PrimitiveDateTime::parse(ts_str, format)
|
||||
@@ -1088,6 +1092,7 @@ async fn test_get_thread_contents() {
|
||||
"timestamp": ts,
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"session_id": uuid,
|
||||
"id": uuid,
|
||||
"timestamp": ts,
|
||||
"cwd": ".",
|
||||
@@ -1267,6 +1272,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> {
|
||||
timestamp: ts.to_string(),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
session_id: conversation_id.into(),
|
||||
id: conversation_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
|
||||
Reference in New Issue
Block a user