store and expose parent_thread_id on Threads (#25113)

## Why

This PR
https://github.com/openai/codex/pull/24161#discussion_r3325692763
revealed a subagent data modeling issue, where we overloaded
`forked_from_id` to also mean `parent_thread_id`. That's incorrect since
guardian and review subagents can be a subagent and NOT fork the main
thread's history.

The solution here is to explicitly store a new `parent_thread_id` on
`SessionMeta`, alongside `forked_from_id` which already exists. While
we're at it, also expose it in the app-server protocol on the `Thread`
object.

A thread->subagent relationship and a fork of thread history are
orthogonal concepts.

## What Changed

- Added top-level `parent_thread_id` persistence on `SessionMeta` and
runtime/session plumbing through `SessionConfiguredEvent`,
`CodexSpawnArgs`, `SessionConfiguration`, `ThreadConfigSnapshot`,
`TurnContext`, and `ModelClient`.
- Made turn metadata, request headers, analytics, and subagent-start
events read the separate runtime/top-level parent field instead of
deriving general parent lineage from `SessionSource` or
`forked_from_thread_id`.
- Passed parent lineage separately at delegated subagent, review,
guardian, agent-job, and multi-agent spawn construction sites;
copied-history fork lineage remains derived only from `InitialHistory`.
- Persisted and exposed parent lineage through rollout/thread-store
projections and app-server v2 `Thread.parentThreadId`.
- Updated app-server README text and regenerated app-server schema
fixtures for the additive `parentThreadId` response field.
This commit is contained in:
Owen Lin
2026-06-01 04:33:20 +00:00
committed by GitHub
parent 3b7334d099
commit cf0911076f
92 changed files with 504 additions and 111 deletions
+1 -1
View File
@@ -32,5 +32,5 @@ fn register_session_root(session: &Arc<Session>, turn: &Arc<TurnContext>) {
session
.services
.agent_control
.register_session_root(session.conversation_id, &turn.session_source);
.register_session_root(session.conversation_id, turn.parent_thread_id);
}
+12 -10
View File
@@ -54,6 +54,7 @@ pub(crate) enum SpawnAgentForkMode {
pub(crate) struct SpawnAgentOptions {
pub(crate) fork_parent_spawn_call_id: Option<String>,
pub(crate) fork_mode: Option<SpawnAgentForkMode>,
pub(crate) parent_thread_id: Option<ThreadId>,
pub(crate) environments: Option<Vec<TurnEnvironmentSelection>>,
}
@@ -262,12 +263,12 @@ impl AgentControl {
.await?
}
(Some(session_source), None) => {
let forked_from_thread_id = thread_spawn_parent_thread_id(&session_source);
Box::pin(state.spawn_new_thread_with_source(
config.clone(),
self.clone(),
session_source,
forked_from_thread_id,
options.parent_thread_id,
/*forked_from_thread_id*/ None,
/*thread_source*/ Some(ThreadSource::Subagent),
/*persist_extended_history*/ false,
/*metrics_service_name*/ None,
@@ -309,6 +310,7 @@ impl AgentControl {
}
};
let thread_config = new_thread.thread.codex.thread_config_snapshot().await;
let parent_thread_id = thread_config.parent_thread_id;
emit_subagent_session_started(
&new_thread
.thread
@@ -319,7 +321,7 @@ impl AgentControl {
client_metadata,
new_thread.thread.codex.session.session_id(),
new_thread.thread_id,
/*parent_thread_id*/ None,
parent_thread_id,
thread_config,
subagent_source.clone(),
);
@@ -490,6 +492,7 @@ impl AgentControl {
self.clone(),
session_source,
/*thread_source*/ Some(ThreadSource::Subagent),
/*parent_thread_id*/ Some(parent_thread_id),
/*forked_from_thread_id*/ Some(parent_thread_id),
/*persist_extended_history*/ false,
inherited_shell_snapshot,
@@ -638,6 +641,7 @@ impl AgentControl {
.history
.ok_or_else(|| CodexErr::ThreadNotFound(thread_id))?
.items;
let parent_thread_id = stored_thread.parent_thread_id;
let resumed_thread = state
.resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions {
@@ -649,6 +653,7 @@ impl AgentControl {
}),
agent_control: self.clone(),
session_source,
parent_thread_id,
inherited_shell_snapshot,
inherited_exec_policy,
})
@@ -841,9 +846,9 @@ impl AgentControl {
pub(crate) fn register_session_root(
&self,
current_thread_id: ThreadId,
current_session_source: &SessionSource,
current_parent_thread_id: Option<ThreadId>,
) {
if thread_spawn_parent_thread_id(current_session_source).is_none() {
if current_parent_thread_id.is_none() {
self.state.register_root_thread(current_thread_id);
}
}
@@ -1218,7 +1223,8 @@ impl AgentControl {
child_thread_id: ThreadId,
session_source: Option<&SessionSource>,
) {
let Some(parent_thread_id) = session_source.and_then(thread_spawn_parent_thread_id) else {
let Some(parent_thread_id) = session_source.and_then(SessionSource::parent_thread_id)
else {
return;
};
let Some(state_db_ctx) = thread.state_db() else {
@@ -1263,10 +1269,6 @@ impl AgentControl {
}
}
fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option<ThreadId> {
session_source.parent_thread_id()
}
fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> bool {
if prefix.is_root() {
return true;
+9
View File
@@ -74,6 +74,15 @@ fn assistant_message(text: &str, phase: Option<MessagePhase>) -> ResponseItem {
}
}
#[test]
fn register_session_root_skips_threads_with_explicit_parent() {
let control = AgentControl::default();
control.register_session_root(ThreadId::new(), Some(ThreadId::new()));
assert_eq!(control.state.agent_id_for_path(&AgentPath::root()), None);
}
fn spawn_agent_call(call_id: &str) -> ResponseItem {
ResponseItem::FunctionCall {
id: None,