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:
jif
2026-06-22 09:36:08 +02:00
committed by GitHub
parent 98845e4840
commit 6d15bb3d17
38 changed files with 193 additions and 34 deletions
@@ -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,
+3
View File
@@ -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,
+17 -5
View File
@@ -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),
+20 -7
View File
@@ -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,
+1
View File
@@ -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,
+7 -1
View File
@@ -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,