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 08:36:08 +01:00
committed by GitHub
Unverified
parent 98845e4840
commit 6d15bb3d17
38 changed files with 193 additions and 34 deletions
+38 -2
View File
@@ -57,7 +57,9 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::de::Error as _;
use serde_json::Value;
use serde_with::serde_as;
use strum_macros::Display;
@@ -2914,6 +2916,7 @@ pub enum MultiAgentVersion {
/// and should be used when there is no config override.
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)]
pub struct SessionMeta {
pub session_id: SessionId,
pub id: ThreadId,
#[serde(skip_serializing_if = "Option::is_none")]
pub forked_from_id: Option<ThreadId>,
@@ -2956,8 +2959,10 @@ pub struct SessionMeta {
impl Default for SessionMeta {
fn default() -> Self {
let id = ThreadId::default();
SessionMeta {
id: ThreadId::default(),
session_id: id.into(),
id,
forked_from_id: None,
parent_thread_id: None,
timestamp: String::new(),
@@ -2978,7 +2983,7 @@ impl Default for SessionMeta {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)]
#[derive(Serialize, Debug, Clone, JsonSchema, TS)]
pub struct SessionMetaLine {
#[serde(flatten)]
pub meta: SessionMeta,
@@ -2986,6 +2991,35 @@ pub struct SessionMetaLine {
pub git: Option<GitInfo>,
}
impl<'de> Deserialize<'de> for SessionMetaLine {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct SessionMetaLineFields {
#[serde(flatten)]
meta: SessionMeta,
git: Option<GitInfo>,
}
let mut value = Value::deserialize(deserializer)?;
let fields = value
.as_object_mut()
.ok_or_else(|| D::Error::custom("session metadata must be an object"))?;
if !fields.contains_key("session_id") {
let thread_id = fields
.get("id")
.cloned()
.ok_or_else(|| D::Error::missing_field("id"))?;
fields.insert("session_id".to_string(), thread_id);
}
let SessionMetaLineFields { meta, git } =
serde_json::from_value(value).map_err(D::Error::custom)?;
Ok(Self { meta, git })
}
}
#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)]
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
pub enum RolloutItem {
@@ -5390,6 +5424,7 @@ mod tests {
let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;
let older_meta = SessionMetaLine {
meta: SessionMeta {
session_id: thread_id.into(),
id: thread_id,
multi_agent_version: Some(MultiAgentVersion::V2),
..Default::default()
@@ -5398,6 +5433,7 @@ mod tests {
};
let newer_meta_without_version = SessionMetaLine {
meta: SessionMeta {
session_id: thread_id.into(),
id: thread_id,
multi_agent_version: None,
..Default::default()