From 0d654e653614b0f99b8da3e4a74aa2558ee61752 Mon Sep 17 00:00:00 2001 From: Brandon Zhang Date: Wed, 26 Aug 2026 22:04:06 +0000 Subject: [PATCH] Track window and fork positions in turn metadata (#40987) ## What changed - Add the zero-based `window_number` to Responses turn metadata and include it in prewarm requests. - Persist `forked_from_ordinal_exclusive` separately from the physical history base so fork lineage remains accurate after reverts and cold resumes. - Report context-inheriting subagents through `parent_thread_id` without also emitting fork lineage. - Reserve the new metadata keys while accepting and filtering previously valid configured values so Codex-owned values take precedence. ## Testing - Cover window numbering across compaction, resume, and fork operations. - Cover fork cutoff persistence across direct forks, legacy rollouts, reverts into inherited history, and cold resumes. GitOrigin-RevId: 288f6cd632254814c1f166668b33828082d799a1 --- .../app-server-exports-stable.json.zst | Bin 142623 -> 142695 bytes codex-rs/app-server/tests/common/rollout.rs | 2 + .../tests/suite/v2/client_metadata.rs | 2 + .../app-server/tests/suite/v2/thread_fork.rs | 31 ++++ .../tests/suite/v2/thread_resume.rs | 1 + .../tests/suite/v2/thread_revert.rs | 160 ++++++++++++++++++ codex-rs/core/src/responses_metadata.rs | 22 ++- codex-rs/core/src/session/mod.rs | 8 +- codex-rs/core/src/session/session.rs | 44 ++++- codex-rs/core/src/session/tests.rs | 49 ++++++ codex-rs/core/src/session_startup_prewarm.rs | 11 +- codex-rs/core/src/turn_metadata.rs | 6 +- codex-rs/core/src/turn_metadata_tests.rs | 64 +++++-- codex-rs/core/tests/suite/agent_websocket.rs | 10 ++ codex-rs/core/tests/suite/compact_remote.rs | 9 +- ...pi_auth_prompt_cache_key_request_diff.snap | 2 +- ...ce_tier_prompt_cache_key_request_diff.snap | 2 +- codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/window_headers.rs | 7 + codex-rs/protocol/src/protocol.rs | 7 + codex-rs/rollout/src/compression_tests.rs | 1 + codex-rs/rollout/src/lib.rs | 1 + codex-rs/rollout/src/metadata.rs | 21 +++ codex-rs/rollout/src/metadata_tests.rs | 84 +++++++++ codex-rs/rollout/src/recorder.rs | 17 ++ codex-rs/rollout/src/recorder_tests.rs | 12 +- codex-rs/rollout/src/session_index_tests.rs | 1 + codex-rs/rollout/src/state_db_tests.rs | 1 + codex-rs/rollout/src/tests.rs | 1 + codex-rs/state/src/extract.rs | 2 + codex-rs/state/src/runtime/threads.rs | 2 + .../thread-store/src/local/create_thread.rs | 6 + .../thread-store/src/local/revert_thread.rs | 19 ++- 33 files changed, 562 insertions(+), 44 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index a78ce21eb9926c44505829b9b5af79097098390e..f55b252c936278212bc9ef04ee61e3078e175dc6 100644 GIT binary patch delta 7186 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 A+5i9m diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 9aebb3f870..1c36d944aa 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -256,6 +256,7 @@ fn create_fake_rollout_with_source_and_parent_thread_id( session_id, id: conversation_id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id, timestamp: meta_rfc3339.to_string(), cwd: test_path_buf("/"), @@ -348,6 +349,7 @@ pub fn create_fake_rollout_with_text_elements( session_id: conversation_id.into(), id: conversation_id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp: meta_rfc3339.to_string(), cwd: test_path_buf("/"), diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index 67b1412039..28f441fd4c 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -116,6 +116,7 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result metadata["window_id"].as_str(), request.header("x-codex-window-id").as_deref() ); + assert_eq!(metadata["window_number"].as_u64(), Some(0)); assert!( metadata["context_window_id"] .as_str() @@ -193,6 +194,7 @@ async fn turn_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() -> metadata["forked_from_thread_id"].as_str(), Some(source_thread_id.as_str()) ); + assert!(metadata.get("forked_from_ordinal_exclusive").is_none()); assert_eq!(metadata["thread_id"].as_str(), Some(thread.id.as_str())); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 5a47d56f1c..5d6729bfbd 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -1442,6 +1442,19 @@ async fn thread_fork_creates_reference_backed_paginated_thread() -> Result<()> { .find(|request| request.url.path().ends_with("/responses")) .expect("forked turn response request"); let request_body = response_request.body_json::()?; + let turn_metadata: Value = serde_json::from_str( + request_body["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("forked turn metadata"), + )?; + assert_eq!( + turn_metadata["forked_from_thread_id"].as_str(), + Some(conversation_id.as_str()) + ); + assert_eq!( + turn_metadata["forked_from_ordinal_exclusive"].as_u64(), + Some(history_base.end_ordinal_exclusive) + ); let model_input = request_body["input"] .as_array() .expect("response input array"); @@ -1702,6 +1715,11 @@ async fn assert_thread_fork_freezes_active_paginated_turn_as_interrupted( .await?; let forked_thread_id = forked_thread.id.clone(); let forked_path = forked_thread.path.expect("forked rollout path"); + let history_base = read_session_meta_line(forked_path.as_path()) + .await? + .meta + .history_base + .expect("fork history base"); let child_rollout = std::fs::read_to_string(forked_path.as_path())? .lines() .map(serde_json::from_str::) @@ -1933,6 +1951,19 @@ async fn assert_thread_fork_freezes_active_paginated_turn_as_interrupted( .find(|request| request.url.path().ends_with("/responses")) .expect("cold-resumed model request") .body_json::()?; + let turn_metadata: Value = serde_json::from_str( + request_body["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("cold-resumed turn metadata"), + )?; + assert_eq!( + turn_metadata["forked_from_thread_id"].as_str(), + Some(source_thread_id.as_str()) + ); + assert_eq!( + turn_metadata["forked_from_ordinal_exclusive"].as_u64(), + Some(history_base.end_ordinal_exclusive) + ); let model_input = request_body["input"].as_array().expect("model input"); assert!(model_input.iter().any(|item| { item["role"] == expected_marker_role diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index f0f5f75351..807705667f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -3766,6 +3766,7 @@ async fn thread_resume_prefers_persisted_git_metadata_for_local_threads() -> Res session_id: conversation_id.into(), id: conversation_id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp: "2025-01-05T12:00:00Z".to_string(), cwd: repo_path.clone(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_revert.rs b/codex-rs/app-server/tests/suite/v2/thread_revert.rs index 7287d2b2bf..5ce27011e1 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_revert.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_revert.rs @@ -13,6 +13,8 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadHistoryMode; use codex_app_server_protocol::ThreadItemsListParams; use codex_app_server_protocol::ThreadItemsListResponse; @@ -34,12 +36,170 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::EventMsg; +use codex_rollout::RolloutItem; +use codex_rollout::RolloutLine; +use codex_rollout::read_session_meta_line; use pretty_assertions::assert_eq; +use serde_json::Value; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +#[tokio::test] +async fn thread_revert_preserves_fork_cutoff_after_cold_resume() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; + initialize_experimental(&mut mcp).await?; + let ThreadStartResponse { thread: parent, .. } = mcp + .start_thread(ThreadStartParams { + history_mode: Some(ThreadHistoryMode::Paginated), + ..Default::default() + }) + .await?; + let mut parent_turns = Vec::new(); + for text in ["parent first", "parent second"] { + let completed = mcp + .start_turn_and_wait_for_completion(TurnStartParams { + thread_id: parent.id.clone(), + input: vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + parent_turns.push(completed.turn.id); + } + let ThreadForkResponse { thread: child, .. } = mcp + .request(|request_id| ClientRequest::ThreadFork { + request_id, + params: ThreadForkParams { + thread_id: parent.id.clone(), + ..Default::default() + }, + }) + .await?; + let child_meta = read_session_meta_line(child.path.as_ref().expect("child rollout")) + .await? + .meta; + let fork_cutoff = child_meta + .history_base + .expect("fork history base") + .end_ordinal_exclusive; + assert_eq!(child_meta.forked_from_ordinal_exclusive, Some(fork_cutoff)); + let inherited_revert_cutoff = + std::fs::read_to_string(parent.path.as_ref().expect("parent rollout"))? + .lines() + .map(serde_json::from_str::) + .collect::, _>>()? + .into_iter() + .find_map(|line| match line.item { + RolloutItem::EventMsg(EventMsg::TurnStarted(turn)) + if turn.turn_id == parent_turns[1] => + { + line.ordinal + } + _ => None, + }) + .expect("inherited turn start ordinal"); + let mut child_turns = Vec::new(); + for text in ["child first", "child second"] { + let completed = mcp + .start_turn_and_wait_for_completion(TurnStartParams { + thread_id: child.id.clone(), + input: vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + child_turns.push(completed.turn.id); + } + + // First revert within the child, then revert into its inherited parent history. + for (before_turn_id, expected_cutoff) in [ + (child_turns[1].clone(), fork_cutoff), + (parent_turns[1].clone(), inherited_revert_cutoff), + ] { + let ThreadRevertResponse { + thread: reverted, .. + } = mcp + .request(|request_id| ClientRequest::ThreadRevert { + request_id, + params: ThreadRevertParams { + thread_id: child.id.clone(), + before_turn_id, + }, + }) + .await?; + let meta = read_session_meta_line(reverted.path.as_ref().expect("reverted rollout")) + .await? + .meta; + assert_eq!(meta.forked_from_ordinal_exclusive, Some(expected_cutoff)); + if expected_cutoff == fork_cutoff { + assert!( + meta.history_base + .expect("child revert base") + .end_ordinal_exclusive + > fork_cutoff + ); + } + + mcp.shutdown_gracefully().await?; + mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build() + .await?; + initialize_experimental(&mut mcp).await?; + let _: ThreadResumeResponse = mcp + .request(|request_id| ClientRequest::ThreadResume { + request_id, + params: ThreadResumeParams { + thread_id: child.id.clone(), + ..Default::default() + }, + }) + .await?; + mcp.start_turn_and_wait_for_completion(TurnStartParams { + thread_id: child.id.clone(), + input: vec![UserInput::Text { + text: "continue after revert and cold resume".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let requests = server.received_requests().await.expect("response requests"); + let body = requests + .iter() + .rev() + .find(|request| request.url.path().ends_with("/responses")) + .expect("resumed model request") + .body_json::()?; + let metadata: Value = serde_json::from_str( + body["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("turn metadata"), + )?; + assert_eq!( + ( + metadata["forked_from_thread_id"].as_str(), + metadata["forked_from_ordinal_exclusive"].as_u64() + ), + (Some(parent.id.as_str()), Some(expected_cutoff)) + ); + } + Ok(()) +} + #[tokio::test] async fn thread_revert_replaces_paginated_history_before_turn() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/core/src/responses_metadata.rs b/codex-rs/core/src/responses_metadata.rs index 61d67d3dfd..698e5a0ca2 100644 --- a/codex-rs/core/src/responses_metadata.rs +++ b/codex-rs/core/src/responses_metadata.rs @@ -30,6 +30,7 @@ pub(crate) const THREAD_ID_KEY: &str = "thread_id"; pub(crate) const AGENT_NAME_KEY: &str = "agent_name"; pub(crate) const TURN_ID_KEY: &str = "turn_id"; pub(crate) const WINDOW_ID_KEY: &str = "window_id"; +pub(crate) const WINDOW_NUMBER_KEY: &str = "window_number"; pub(crate) const CONTEXT_WINDOW_ID_KEY: &str = "context_window_id"; pub(crate) const REQUEST_KIND_KEY: &str = "request_kind"; pub(crate) const COMPACTION_KEY: &str = "compaction"; @@ -39,6 +40,7 @@ pub(crate) const TOOL_NAMESPACES_INFO_KEY: &str = "tool_namespaces_info"; pub(crate) const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; pub(crate) const FORKED_FROM_THREAD_ID_KEY: &str = "forked_from_thread_id"; +pub(crate) const FORKED_FROM_ORDINAL_EXCLUSIVE_KEY: &str = "forked_from_ordinal_exclusive"; pub(crate) const PARENT_THREAD_ID_KEY: &str = "parent_thread_id"; pub(crate) const PARENT_TURN_ID_KEY: &str = "parent_turn_id"; pub(crate) const ROOT_TURN_ID_KEY: &str = "root_turn_id"; @@ -62,6 +64,7 @@ const RESERVED_METADATA_KEYS: &[&str] = &[ AGENT_NAME_KEY, TURN_ID_KEY, WINDOW_ID_KEY, + WINDOW_NUMBER_KEY, CONTEXT_WINDOW_ID_KEY, X_CODEX_WINDOW_ID_HEADER, X_CODEX_TURN_METADATA_HEADER, @@ -73,6 +76,7 @@ const RESERVED_METADATA_KEYS: &[&str] = &[ TOOL_NAMESPACES_INFO_KEY, TURN_STARTED_AT_UNIX_MS_KEY, FORKED_FROM_THREAD_ID_KEY, + FORKED_FROM_ORDINAL_EXCLUSIVE_KEY, PARENT_THREAD_ID_KEY, PARENT_TURN_ID_KEY, ROOT_TURN_ID_KEY, @@ -86,6 +90,10 @@ const RESERVED_METADATA_KEYS: &[&str] = &[ NODE_REPL_DISABLED_KEY, WORKSPACES_KEY, ]; +// These keys were previously valid user configuration. Accept existing configs while filtering +// their values before constructing Core-owned request metadata. +const BACKWARD_COMPATIBLE_RESERVED_METADATA_KEYS: &[&str] = + &[WINDOW_NUMBER_KEY, FORKED_FROM_ORDINAL_EXCLUSIVE_KEY]; const MAX_EXTRA_METADATA_ENTRIES: usize = 16; const MAX_EXTRA_METADATA_KEY_BYTES: usize = 64; const MAX_EXTRA_METADATA_VALUE_BYTES: usize = 128; @@ -214,9 +222,11 @@ pub struct CodexResponsesMetadata { pub(crate) turn_id: Option, pub(crate) routing_hint: Option, pub(crate) window_id: String, + pub(crate) window_number: Option, pub(crate) context_window_id: Option, pub(crate) request_kind: Option, pub(crate) forked_from_thread_id: Option, + pub(crate) forked_from_ordinal_exclusive: Option, pub(crate) parent_thread_id: Option, pub(crate) parent_turn_id: Option, pub(crate) root_turn_id: Option, @@ -250,9 +260,11 @@ impl CodexResponsesMetadata { turn_id: None, routing_hint: None, window_id, + window_number: None, context_window_id: None, request_kind: None, forked_from_thread_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, parent_turn_id: None, root_turn_id: None, @@ -374,11 +386,13 @@ impl CodexResponsesMetadata { .then_some(self.turn_id.as_deref()) .flatten(), window_id: has_request_identity.then_some(self.window_id.as_str()), + window_number: has_request_identity.then_some(self.window_number).flatten(), context_window_id: has_request_identity .then_some(self.context_window_id) .flatten(), request_kind: request_kind_value, forked_from_thread_id: self.forked_from_thread_id, + forked_from_ordinal_exclusive: self.forked_from_ordinal_exclusive, parent_thread_id: self.parent_thread_id, parent_turn_id: self.parent_turn_id.as_deref(), root_turn_id: self.root_turn_id.as_deref(), @@ -452,7 +466,9 @@ pub(crate) fn validate_extra_metadata<'a>( if key.len() > MAX_EXTRA_METADATA_KEY_BYTES || !valid_extra_metadata_key(key) { return Err("responses_api_metadata keys must be short ASCII identifiers"); } - if RESERVED_METADATA_KEYS.contains(&key.as_str()) { + if RESERVED_METADATA_KEYS.contains(&key.as_str()) + && !BACKWARD_COMPATIBLE_RESERVED_METADATA_KEYS.contains(&key.as_str()) + { return Err("responses_api_metadata contains a reserved key"); } if value.len() > MAX_EXTRA_METADATA_VALUE_BYTES { @@ -498,12 +514,16 @@ struct CodexTurnMetadataPayload<'a> { #[serde(default, skip_serializing_if = "Option::is_none")] window_id: Option<&'a str>, #[serde(default, skip_serializing_if = "Option::is_none")] + window_number: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] context_window_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] request_kind: Option<&'static str>, #[serde(default, skip_serializing_if = "Option::is_none")] forked_from_thread_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + forked_from_ordinal_exclusive: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] parent_thread_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] parent_turn_id: Option<&'a str>, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index c48c016b61..04d47879e0 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3888,12 +3888,16 @@ impl Session { self.current_window().await.0 } - pub(crate) async fn current_window(&self) -> (String, Uuid) { + pub(crate) async fn current_window(&self) -> (String, u64, Uuid) { let state = self.state.lock().await; let thread_id = self.thread_id; let window_number = state.auto_compact_window_number(); let context_window_id = state.auto_compact_window_ids().window_id; - (format!("{thread_id}:{window_number}"), context_window_id) + ( + format!("{thread_id}:{window_number}"), + window_number, + context_window_id, + ) } pub(crate) async fn advance_auto_compact_window(&self) -> (u64, AutoCompactWindowIds) { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index ffbec36a73..e0250aee6d 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -68,6 +68,7 @@ pub(crate) struct Session { pub(crate) services: SessionServices, pub(super) git_enrichment_policy: GitEnrichmentPolicy, pub(super) fork_persistence: ForkPersistence, + pub(super) forked_from_ordinal_exclusive: Option, pub(super) next_internal_sub_id: AtomicU64, } @@ -599,14 +600,19 @@ impl Session { turn_context: &TurnContext, request_kind: CodexResponsesRequestKind, ) -> CodexResponsesMetadata { - let (window_id, context_window_id) = self.current_window().await; + let (window_id, window_number, context_window_id) = self.current_window().await; + let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata( + self.installation_id.clone(), + window_id, + request_kind, + ); CodexResponsesMetadata { + window_number: Some(window_number), context_window_id: Some(context_window_id), - ..turn_context.turn_metadata_state.to_responses_metadata( - self.installation_id.clone(), - window_id, - request_kind, - ) + forked_from_ordinal_exclusive: self + .forked_from_ordinal_exclusive + .filter(|_| responses_metadata.forked_from_thread_id.is_some()), + ..responses_metadata } } @@ -680,6 +686,31 @@ impl Session { .forked_from_thread_id .or_else(|| initial_history.forked_from_id()); session_configuration.forked_from_thread_id = forked_from_id; + let forked_from_ordinal_exclusive = match &fork_persistence { + ForkPersistence::Referenced { history_base, .. } => { + history_base.map(|position| position.end_ordinal_exclusive) + } + ForkPersistence::Copied => match &initial_history { + InitialHistory::Resumed(resumed) => { + // Both local and CCA thread stores place the resumed thread's + // canonical SessionMeta first. Never inspect inherited metadata: + // an ancestor's history_base describes a different fork boundary. + resumed.history.first().and_then(|item| match item { + RolloutItem::SessionMeta(meta) + if meta.meta.id == resumed.conversation_id => + { + codex_rollout::forked_from_ordinal_exclusive( + &meta.meta, + resumed.rollout_path.as_deref(), + ) + } + _ => None, + }) + } + InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None, + }, + } + .filter(|_| forked_from_id.is_some()); let parent_thread_id = session_configuration .parent_thread_id .or_else(|| initial_history.get_resumed_parent_thread_id()); @@ -1430,6 +1461,7 @@ impl Session { services, git_enrichment_policy, fork_persistence, + forked_from_ordinal_exclusive, next_internal_sub_id: AtomicU64::new(0), }); if let Some(network_policy_decider_session) = network_policy_decider_session { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 88ddab0c66..3889978a0a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -141,6 +141,7 @@ use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::CreditsSnapshot; use codex_protocol::protocol::GranularApprovalConfig; +use codex_protocol::protocol::HistoryPosition; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::NetworkApprovalProtocol; @@ -6314,6 +6315,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { services, git_enrichment_policy: GitEnrichmentPolicy::Fresh, fork_persistence: ForkPersistence::Copied, + forked_from_ordinal_exclusive: None, next_internal_sub_id: AtomicU64::new(0), }; let per_turn_config = @@ -6709,6 +6711,52 @@ async fn resumed_subagent_session_restores_persisted_session_id() { assert_eq!(event.thread_id, thread_id); } +#[tokio::test] +async fn resumed_copied_fork_ignores_source_history_base() { + let ancestor_thread_id = ThreadId::new(); + let parent_thread_id = ThreadId::new(); + let thread_id = ThreadId::new(); + let history = vec![ + RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: thread_id, + session_id: SessionId::from(thread_id), + forked_from_id: Some(parent_thread_id), + ..SessionMeta::default() + }, + git: None, + }), + RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: parent_thread_id, + session_id: SessionId::from(parent_thread_id), + forked_from_id: Some(ancestor_thread_id), + history_base: Some(HistoryPosition { + thread_id: ancestor_thread_id, + end_ordinal_exclusive: 42, + end_byte_offset: 100, + }), + ..SessionMeta::default() + }, + git: None, + }), + ]; + let (session, _rx_event) = make_session_with_history_source_and_agent_control_and_rx( + InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: Arc::new(history), + rollout_path: None, + }), + SessionSource::Exec, + AgentControl::default(), + ) + .await + .expect("resume should succeed"); + + assert_eq!(session.thread_id(), thread_id); + assert_eq!(session.forked_from_ordinal_exclusive, None); +} + #[tokio::test] async fn notify_request_permissions_response_ignores_unmatched_call_id() { let (session, _turn_context) = make_session_and_context().await; @@ -8545,6 +8593,7 @@ where services, git_enrichment_policy: GitEnrichmentPolicy::Fresh, fork_persistence: ForkPersistence::Copied, + forked_from_ordinal_exclusive: None, next_internal_sub_id: AtomicU64::new(0), }); let per_turn_config = diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 70a9efea2f..4b61d92b83 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -313,14 +313,9 @@ async fn schedule_startup_prewarm_inner( build_prompt_started_at.elapsed(), /*status*/ None, ); - let window_id = session.current_window_id().await; - let responses_metadata = startup_turn_context - .turn_metadata_state - .to_responses_metadata( - session.installation_id.clone(), - window_id, - CodexResponsesRequestKind::Prewarm, - ); + let responses_metadata = session + .responses_metadata(&startup_turn_context, CodexResponsesRequestKind::Prewarm) + .await; let mut client_session = session.services.model_client.new_session(); let websocket_warmup_started_at = Instant::now(); client_session diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index c8c71a5985..afae92f22f 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -357,7 +357,8 @@ impl TurnMetadataState { *self .responses_api_metadata .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) = responses_api_metadata; + .unwrap_or_else(std::sync::PoisonError::into_inner) = + filter_extra_metadata(responses_api_metadata); } pub(crate) fn workspace_kind(&self) -> Option { @@ -370,6 +371,9 @@ impl TurnMetadataState { fn responses_metadata_template(&self) -> CodexResponsesMetadata { let mut metadata = self.mcp_metadata_template(); + if metadata.parent_thread_id.is_some() { + metadata.forked_from_thread_id = None; + } metadata.extra.extend( self.responses_api_metadata .read() diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index 3d27bd8956..132bd644be 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -4,6 +4,7 @@ use crate::responses_metadata::AUTO_REVIEW_ENABLED_KEY; use crate::responses_metadata::CONTEXT_WINDOW_ID_KEY; use crate::responses_metadata::CodexResponsesRequestKind; use crate::responses_metadata::CompactionTurnMetadata; +use crate::responses_metadata::FORKED_FROM_ORDINAL_EXCLUSIVE_KEY; use crate::responses_metadata::INSTALLATION_ID_KEY; use crate::responses_metadata::LEGACY_CODE_MODE_TOOL_NAMES_KEY; use crate::responses_metadata::NODE_REPL_AUTO_REVIEW_REQUIRED_KEY; @@ -17,6 +18,7 @@ use crate::responses_metadata::TurnToolFunctionInfo; use crate::responses_metadata::TurnToolNamespaceInfo; use crate::responses_metadata::TurnToolSource; use crate::responses_metadata::WINDOW_ID_KEY; +use crate::responses_metadata::WINDOW_NUMBER_KEY; use crate::responses_metadata::validate_extra_metadata; use crate::sandbox_tags::permission_profile_sandbox_tag; use codex_analytics::CompactionImplementation; @@ -342,7 +344,7 @@ fn turn_metadata_state_includes_thread_spawn_subagent_parent_without_fork() { } #[test] -fn turn_metadata_state_includes_forked_thread_spawn_subagent_lineage() { +fn turn_metadata_state_omits_fork_lineage_for_context_inheriting_subagent() { let temp_dir = TempDir::new().expect("temp dir"); let cwd = temp_dir.path().abs(); let permission_profile = PermissionProfile::read_only(); @@ -374,10 +376,8 @@ fn turn_metadata_state_includes_forked_thread_spawn_subagent_lineage() { let header = test_turn_metadata_header(&state); let json: Value = serde_json::from_str(&header).expect("json"); - assert_eq!( - json["forked_from_thread_id"].as_str(), - Some("33333333-3333-4333-8333-333333333333") - ); + assert!(json.get("forked_from_thread_id").is_none()); + assert!(json.get(FORKED_FROM_ORDINAL_EXCLUSIVE_KEY).is_none()); assert_eq!( json["parent_thread_id"].as_str(), Some("33333333-3333-4333-8333-333333333333") @@ -386,6 +386,14 @@ fn turn_metadata_state_includes_forked_thread_spawn_subagent_lineage() { // V1 subagents have no canonical agent path and are intentionally unsupported by // agent-name-addressed history and notes; their metadata falls back to the root agent. assert_eq!(json["agent_name"].as_str(), Some("/root")); + + let mcp_metadata = state + .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) + .expect("MCP request metadata"); + assert_eq!( + mcp_metadata["forked_from_thread_id"].as_str(), + Some("33333333-3333-4333-8333-333333333333") + ); } #[test] @@ -679,10 +687,17 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( /*auto_review_enabled*/ false, &model_info_from_slug("gpt-5.4"), ); - state.set_responses_api_metadata(BTreeMap::from([( - "codex_security_surface".to_string(), - "sdk".to_string(), - )])); + state.set_responses_api_metadata(BTreeMap::from([ + ("codex_security_surface".to_string(), "sdk".to_string()), + ( + WINDOW_NUMBER_KEY.to_string(), + "configured-value".to_string(), + ), + ( + FORKED_FROM_ORDINAL_EXCLUSIVE_KEY.to_string(), + "configured-value".to_string(), + ), + ])); state.set_parent_turn_id("parent-turn-a".to_string()); state.set_root_turn_id("root-turn-a".to_string()); state.set_turn_trigger("goal".to_string()); @@ -736,10 +751,15 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( ), ("turn_id".to_string(), "client-supplied".to_string()), (WINDOW_ID_KEY.to_string(), "client-supplied".to_string()), + (WINDOW_NUMBER_KEY.to_string(), "client-supplied".to_string()), ( CONTEXT_WINDOW_ID_KEY.to_string(), "client-supplied".to_string(), ), + ( + FORKED_FROM_ORDINAL_EXCLUSIVE_KEY.to_string(), + "client-supplied".to_string(), + ), ("thread_source".to_string(), "client-supplied".to_string()), (TURN_TRIGGER_KEY.to_string(), "client-supplied".to_string()), ("request_kind".to_string(), "client-supplied".to_string()), @@ -807,10 +827,8 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert!(json.get("x-codex-installation-id").is_none()); assert!(json.get("x-codex-parent-thread-id").is_none()); assert!(json.get("x-openai-subagent").is_none()); - assert_eq!( - json["forked_from_thread_id"].as_str(), - Some("44444444-4444-4444-8444-444444444444") - ); + assert!(json.get("forked_from_thread_id").is_none()); + assert!(json.get(FORKED_FROM_ORDINAL_EXCLUSIVE_KEY).is_none()); assert_eq!( json["parent_thread_id"].as_str(), Some("55555555-5555-4555-8555-555555555555") @@ -823,6 +841,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert_eq!(json["turn_id"].as_str(), Some("turn-a")); assert!(json.get("request_kind").is_none()); assert!(json.get(WINDOW_ID_KEY).is_none()); + assert!(json.get(WINDOW_NUMBER_KEY).is_none()); assert!(json.get(CONTEXT_WINDOW_ID_KEY).is_none()); assert_eq!( json["turn_started_at_unix_ms"].as_i64(), @@ -974,8 +993,20 @@ fn responses_api_metadata_rejects_reserved_keys() { } } +#[test] +fn responses_api_metadata_accepts_previously_valid_rollout_position_keys() { + for legacy_key in [WINDOW_NUMBER_KEY, FORKED_FROM_ORDINAL_EXCLUSIVE_KEY] { + assert_eq!( + validate_extra_metadata( + BTreeMap::from([(legacy_key.to_string(), "legacy-value".to_string())]).iter() + ), + Ok(()) + ); + } +} + #[tokio::test] -async fn turn_metadata_state_preserves_lineage_after_git_enrichment() { +async fn turn_metadata_state_preserves_subagent_parent_after_git_enrichment() { let (_temp_dir, repo_path) = create_clean_git_repo("repo").await; let permission_profile = PermissionProfile::read_only(); @@ -1006,10 +1037,7 @@ async fn turn_metadata_state_preserves_lineage_after_git_enrichment() { state.spawn_git_enrichment_task(); let json = wait_for_git_enrichment(&state).await; - assert_eq!( - json["forked_from_thread_id"].as_str(), - Some("66666666-6666-4666-8666-666666666666") - ); + assert!(json.get("forked_from_thread_id").is_none()); assert_eq!( json["parent_thread_id"].as_str(), Some("66666666-6666-4666-8666-666666666666") diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 3b3b8afa00..cad03435cf 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -217,6 +217,16 @@ async fn websocket_first_turn_uses_startup_prewarm_and_create() -> Result<()> { .expect("turn metadata"), )?; assert_eq!(turn_metadata["request_kind"].as_str(), Some("turn")); + assert_eq!(warmup_metadata["window_number"].as_u64(), Some(0)); + assert_eq!( + warmup_metadata["window_number"], + turn_metadata["window_number"] + ); + assert!(warmup_metadata["context_window_id"].is_string()); + assert_eq!( + warmup_metadata["context_window_id"], + turn_metadata["context_window_id"] + ); server.shutdown().await; Ok(()) diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index f0f921cf06..c19f60ea91 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -51,6 +51,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::RealtimeOutputModality; +use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::PathBufExt; @@ -770,7 +771,9 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { skip_if_no_network!(Ok(())); let harness = TestCodexHarness::with_builder( - test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()), + test_codex() + .with_history_mode(ThreadHistoryMode::Paginated) + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()), ) .await?; let codex = harness.test().codex.clone(); @@ -865,6 +868,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { compact_metadata["window_id"].as_str(), compact_request.header("x-codex-window-id").as_deref() ); + assert_eq!(compact_metadata["window_number"].as_u64(), Some(0)); assert!(compact_metadata["context_window_id"].as_str().is_some()); assert_eq!( compact_metadata["compaction"], @@ -1828,6 +1832,7 @@ async fn remote_compact_v2_retries_failures_with_stream_retry_budget() -> Result let harness = TestCodexHarness::with_builder( test_codex() + .with_history_mode(ThreadHistoryMode::Paginated) .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { let _ = config.features.enable(Feature::RemoteCompactionV2); @@ -1896,7 +1901,6 @@ async fn remote_compact_v2_retries_failures_with_stream_retry_budget() -> Result response_requests.len(), "expected initial turn, failed open, failed stream, compact retry, and follow-up turn" ); - for compact_request in &response_requests[1..=3] { assert_eq!("/v1/responses", compact_request.path()); let compact_metadata: Value = serde_json::from_str( @@ -1904,6 +1908,7 @@ async fn remote_compact_v2_retries_failures_with_stream_retry_budget() -> Result .header("x-codex-turn-metadata") .expect("v2 compact request should include turn metadata"), )?; + assert_eq!(compact_metadata["window_number"].as_u64(), Some(0)); assert!(compact_metadata["context_window_id"].as_str().is_some()); assert!( compact_request diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap index 2dad0171e8..c1da677d74 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap @@ -12,7 +12,7 @@ Scenario: After five varied API-key-auth turns, remote manual compaction omits s - "thread_id": "", - "turn_id": "", - "x-codex-installation-id": "", -- "x-codex-turn-metadata": "{\"installation_id\":\"\",\"session_id\":\"\",\"thread_id\":\"\",\"agent_name\":\"/root\",\"turn_id\":\"\",\"window_id\":\":0\",\"context_window_id\":\"\",\"request_kind\":\"turn\",\"root_turn_id\":\"\",\"sandbox\":\"\",\"sandbox_mode\":\"danger-full-access\",\"auto_review_enabled\":false,\"node_repl_auto_review_required\":false,\"node_repl_disabled\":false,\"turn_started_at_unix_ms\":}", +- "x-codex-turn-metadata": "{\"installation_id\":\"\",\"session_id\":\"\",\"thread_id\":\"\",\"agent_name\":\"/root\",\"turn_id\":\"\",\"window_id\":\":0\",\"window_number\":0,\"context_window_id\":\"\",\"request_kind\":\"turn\",\"root_turn_id\":\"\",\"sandbox\":\"\",\"sandbox_mode\":\"danger-full-access\",\"auto_review_enabled\":false,\"node_repl_auto_review_required\":false,\"node_repl_disabled\":false,\"turn_started_at_unix_ms\":}", - "x-codex-window-id": ":0" - }, - "include": [ diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap index c4f8bc6830..82a1b699b9 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap @@ -12,7 +12,7 @@ Scenario: After five varied ChatGPT-auth turns, remote manual compaction reuses - "thread_id": "", - "turn_id": "", - "x-codex-installation-id": "", -- "x-codex-turn-metadata": "{\"installation_id\":\"\",\"session_id\":\"\",\"thread_id\":\"\",\"agent_name\":\"/root\",\"turn_id\":\"\",\"window_id\":\":0\",\"context_window_id\":\"\",\"request_kind\":\"turn\",\"root_turn_id\":\"\",\"sandbox\":\"\",\"sandbox_mode\":\"danger-full-access\",\"auto_review_enabled\":false,\"node_repl_auto_review_required\":false,\"node_repl_disabled\":false,\"turn_started_at_unix_ms\":}", +- "x-codex-turn-metadata": "{\"installation_id\":\"\",\"session_id\":\"\",\"thread_id\":\"\",\"agent_name\":\"/root\",\"turn_id\":\"\",\"window_id\":\":0\",\"window_number\":0,\"context_window_id\":\"\",\"request_kind\":\"turn\",\"root_turn_id\":\"\",\"sandbox\":\"\",\"sandbox_mode\":\"danger-full-access\",\"auto_review_enabled\":false,\"node_repl_auto_review_required\":false,\"node_repl_disabled\":false,\"turn_started_at_unix_ms\":}", - "x-codex-window-id": ":0" - }, - "include": [ diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index a5c8c08923..f28dd835d7 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -417,6 +417,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { session_id: thread_id.into(), id: thread_id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp: "2026-01-27T12:00:00Z".to_string(), cwd: codex_home.to_path_buf(), diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index a0a2312e93..22bbd2d8e0 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -129,6 +129,13 @@ async fn window_id_advances_after_compact_persists_on_resume_and_resets_on_fork( metadata[2]["context_window_id"], metadata[3]["context_window_id"] ); + assert_eq!( + metadata + .iter() + .map(|metadata| metadata["window_number"].as_u64()) + .collect::>(), + vec![Some(0), Some(0), Some(1), Some(1), Some(0)] + ); Ok(()) } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 3d09b2f548..0ab7c8d6df 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2976,6 +2976,10 @@ pub struct SessionMeta { pub id: ThreadId, #[serde(skip_serializing_if = "Option::is_none")] pub forked_from_id: Option, + /// Exclusive ordinal inherited from the logical fork parent, independent of `history_base`. + /// Revert may replace the physical history base while retaining this fork boundary. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub forked_from_ordinal_exclusive: Option, #[serde(skip_serializing_if = "Option::is_none")] pub parent_thread_id: Option, pub timestamp: String, @@ -3037,6 +3041,7 @@ impl Default for SessionMeta { session_id: id.into(), id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp: String::new(), cwd: PathBuf::new(), @@ -5885,7 +5890,9 @@ mod tests { assert_eq!(session_meta.history_mode, ThreadHistoryMode::Legacy); assert_eq!(session_meta.history_base, None); + assert_eq!(session_meta.forked_from_ordinal_exclusive, None); let serialized = serde_json::to_value(&session_meta)?; + assert!(serialized.get("forked_from_ordinal_exclusive").is_none()); assert_eq!(serialized["history_mode"], json!("legacy")); let mut unknown = serialized; unknown["history_mode"] = json!("future"); diff --git a/codex-rs/rollout/src/compression_tests.rs b/codex-rs/rollout/src/compression_tests.rs index 00cba45b42..48a862ae07 100644 --- a/codex-rs/rollout/src/compression_tests.rs +++ b/codex-rs/rollout/src/compression_tests.rs @@ -697,6 +697,7 @@ fn write_rollout(path: &std::path::Path, thread_id: ThreadId, message: &str) -> session_id: thread_id.into(), id: thread_id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp: "2025-01-03T12:00:00Z".to_string(), cwd: parent.to_path_buf(), diff --git a/codex-rs/rollout/src/lib.rs b/codex-rs/rollout/src/lib.rs index 9f38cd569a..04733d33bf 100644 --- a/codex-rs/rollout/src/lib.rs +++ b/codex-rs/rollout/src/lib.rs @@ -113,6 +113,7 @@ pub use list::rollout_date_parts; pub use maintenance::RolloutMaintenanceGuard; pub use maintenance::try_acquire_rollout_maintenance_lock; pub use metadata::builder_from_items; +pub use metadata::forked_from_ordinal_exclusive; pub use metadata::rollout_id_from_path; pub use model_context::ModelContextScan; pub use model_context::ModelContextScanProgress; diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index 62407790ce..3ac5a744cb 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -12,6 +12,7 @@ use chrono::Utc; use codex_protocol::RolloutId; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadHistoryMode; @@ -109,6 +110,26 @@ pub fn rollout_id_from_path(rollout_path: &Path) -> Option { Some(RolloutFileName::parse(file_name)?.rollout_id()) } +/// Reads the logical fork cutoff without mistaking a revert's history base for its parent. +/// +/// Older rollouts lack the explicit cutoff. Their history base is safe to use only when it +/// names the logical parent directly or the current file is the thread's original rollout. +/// An ambiguous legacy revert omits the cutoff rather than reporting another thread's boundary. +pub fn forked_from_ordinal_exclusive( + meta: &SessionMeta, + rollout_path: Option<&Path>, +) -> Option { + let parent_id = meta.forked_from_id?; + meta.forked_from_ordinal_exclusive.or_else(|| { + meta.history_base + .filter(|base| { + base.thread_id == parent_id + || rollout_path.and_then(rollout_id_from_path) == Some(meta.id) + }) + .map(|base| base.end_ordinal_exclusive) + }) +} + pub async fn extract_metadata_from_rollout( rollout_path: &Path, default_provider: &str, diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index afea81849f..9ab474b13a 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -11,6 +11,7 @@ use chrono::Utc; use codex_protocol::SanitizedGitUrl; use codex_protocol::ThreadId; use codex_protocol::protocol::GitInfo; +use codex_protocol::protocol::HistoryPosition; use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; @@ -26,6 +27,86 @@ use std::path::PathBuf; use tempfile::tempdir; use uuid::Uuid; +#[test] +fn fork_cutoff_distinguishes_logical_parent_from_reverted_rollout() { + let parent_id = ThreadId::new(); + let thread_id = ThreadId::new(); + let physical_id = ThreadId::new(); + let replacement_id = ThreadId::new(); + let original_path = PathBuf::from(format!("rollout-2026-01-27T12-34-56-{thread_id}.jsonl")); + let reverted_path = PathBuf::from(format!( + "rollout-2026-01-27T12-34-56-{thread_id}_{replacement_id}.jsonl" + )); + + for (name, parent, cutoff, base_id, path, expected) in [ + ( + "persisted revert", + Some(parent_id), + Some(20), + thread_id, + Some(reverted_path.as_path()), + Some(20), + ), + ( + "legacy direct fork", + Some(parent_id), + None, + parent_id, + None, + Some(40), + ), + ( + "legacy fork of reverted parent", + Some(parent_id), + None, + physical_id, + Some(original_path.as_path()), + Some(40), + ), + ( + "legacy child revert", + Some(parent_id), + None, + thread_id, + Some(reverted_path.as_path()), + None, + ), + ( + "legacy repeated revert", + Some(parent_id), + None, + physical_id, + Some(reverted_path.as_path()), + None, + ), + ( + "missing parent", + None, + Some(20), + parent_id, + Some(original_path.as_path()), + None, + ), + ] { + let meta = SessionMeta { + id: thread_id, + forked_from_id: parent, + forked_from_ordinal_exclusive: cutoff, + history_base: Some(HistoryPosition { + thread_id: base_id, + end_ordinal_exclusive: 40, + end_byte_offset: 100, + }), + ..SessionMeta::default() + }; + assert_eq!( + forked_from_ordinal_exclusive(&meta, path), + expected, + "{name}" + ); + } +} + #[tokio::test] async fn extract_metadata_from_rollout_uses_session_meta() { let dir = tempdir().expect("tempdir"); @@ -39,6 +120,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { session_id: id.into(), id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp: "2026-01-27T12:34:56Z".to_string(), cwd: dir.path().to_path_buf(), @@ -137,6 +219,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() { session_id: id.into(), id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp: "2026-01-27T12:34:56Z".to_string(), cwd: dir.path().to_path_buf(), @@ -477,6 +560,7 @@ fn write_rollout_in_sessions_with_cwd( session_id: id.into(), id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp: event_ts.to_string(), cwd, diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 394a6d3e9d..1ca019356e 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -103,6 +103,7 @@ pub enum RolloutRecorderParams { /// thread ID stable while creating a new immutable rollout file. rollout_id_override: Option, forked_from_id: Option, + forked_from_ordinal_exclusive: Option, parent_thread_id: Option, source: Box, thread_source: Option, @@ -199,6 +200,7 @@ impl RolloutRecorderParams { conversation_id, rollout_id_override: None, forked_from_id, + forked_from_ordinal_exclusive: None, parent_thread_id, source: Box::new(source), thread_source, @@ -283,6 +285,18 @@ impl RolloutRecorderParams { self } + /// Set the logical fork boundary independently of the physical history base. + pub fn with_forked_from_ordinal_exclusive(mut self, cutoff: Option) -> Self { + if let Self::Create { + forked_from_ordinal_exclusive, + .. + } = &mut self + { + *forked_from_ordinal_exclusive = cutoff; + } + self + } + pub fn with_subagent_history_start_ordinal( mut self, subagent_history_start_ordinal: Option, @@ -830,6 +844,7 @@ impl RolloutRecorder { conversation_id, rollout_id_override, forked_from_id, + forked_from_ordinal_exclusive, parent_thread_id, source, thread_source, @@ -860,6 +875,8 @@ impl RolloutRecorder { session_id, id: conversation_id, forked_from_id, + forked_from_ordinal_exclusive: forked_from_ordinal_exclusive + .filter(|_| forked_from_id.is_some()), parent_thread_id, timestamp, cwd: cwd.clone(), diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 8ccad419b4..0056d0ef77 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -188,6 +188,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { session_id: thread_id.into(), id: thread_id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp: "2026-01-27T12:34:56Z".to_string(), cwd: home.path().to_path_buf(), @@ -728,7 +729,7 @@ async fn referenced_paginated_rollout_starts_at_history_cutoff_and_resumes() -> &config, RolloutRecorderParams::new( ThreadId::new(), - /*forked_from_id*/ None, + Some(history_base.thread_id), /*parent_thread_id*/ None, SessionSource::Exec, /*thread_source*/ None, @@ -737,13 +738,20 @@ async fn referenced_paginated_rollout_starts_at_history_cutoff_and_resumes() -> Vec::new(), ) .with_history_mode(ThreadHistoryMode::Paginated) - .with_history_base(Some(history_base)), + .with_history_base(Some(history_base)) + .with_forked_from_ordinal_exclusive(Some(history_base.end_ordinal_exclusive)), ) .await?; let rollout_path = recorder.rollout_path().to_path_buf(); recorder.persist().await?; recorder.shutdown().await?; + let meta = crate::read_session_meta_line(&rollout_path).await?.meta; + assert_eq!( + meta.forked_from_ordinal_exclusive, + Some(history_base.end_ordinal_exclusive) + ); + let resumed = RolloutRecorder::new(&config, RolloutRecorderParams::resume(rollout_path.clone())).await?; resumed diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index 419701e537..47c0eaff37 100644 --- a/codex-rs/rollout/src/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -38,6 +38,7 @@ fn write_rollout_with_source_and_provider( session_id: thread_id.into(), id: thread_id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp, cwd: ".".into(), diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index 6fd9427f65..ad38541c29 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -328,6 +328,7 @@ fn write_rollout_with_user_message( session_id: thread_id.into(), id: thread_id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp: "2026-06-01T14:26:25Z".to_string(), cwd: home.to_path_buf(), diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 0e6180a41a..30ba0bd8e8 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1454,6 +1454,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { session_id: conversation_id.into(), id: conversation_id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp: ts.to_string(), cwd: ".".into(), diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index b5cbd93de6..af4f291577 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -394,6 +394,7 @@ mod tests { forked_from_id: Some( ThreadId::from_string(&Uuid::now_v7().to_string()).expect("thread id"), ), + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp: "2026-02-26T00:00:00.000Z".to_string(), cwd: PathBuf::from("/child/worktree"), @@ -651,6 +652,7 @@ mod tests { session_id: thread_id.into(), id: thread_id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp: "2026-02-26T00:00:00.000Z".to_string(), cwd: PathBuf::from("/workspace"), diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 86200c59ca..3d2e0d923c 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -2568,6 +2568,7 @@ mod tests { session_id: thread_id.into(), id: thread_id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp: metadata.created_at.to_rfc3339(), cwd: PathBuf::new(), @@ -2638,6 +2639,7 @@ mod tests { session_id: thread_id.into(), id: thread_id, forked_from_id: None, + forked_from_ordinal_exclusive: None, parent_thread_id: None, timestamp: created_at, cwd: PathBuf::new(), diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index 3fab5ea761..3be4c808bb 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -42,6 +42,12 @@ pub(super) async fn create_thread( .with_multi_agent_version(params.multi_agent_version) .with_history_mode(params.history_mode) .with_history_base(params.history_base) + .with_forked_from_ordinal_exclusive( + params + .forked_from_id + .and(params.history_base) + .map(|base| base.end_ordinal_exclusive), + ) .with_subagent_history_start_ordinal(params.subagent_history_start_ordinal) .with_initial_window_id(params.initial_window_id), ) diff --git a/codex-rs/thread-store/src/local/revert_thread.rs b/codex-rs/thread-store/src/local/revert_thread.rs index 1bb40be400..e2418f7845 100644 --- a/codex-rs/thread-store/src/local/revert_thread.rs +++ b/codex-rs/thread-store/src/local/revert_thread.rs @@ -96,9 +96,22 @@ pub(super) async fn revert( ) .await?; + let forked_from_ordinal_exclusive = + codex_rollout::forked_from_ordinal_exclusive(&source_meta, Some(source_path.as_path())) + .map(|cutoff| { + // Reverting into inherited history can shrink, but never grow, the parent prefix. + cutoff.min(history_base.map_or(0, |base| base.end_ordinal_exclusive)) + }); + let rollout_id = ThreadId::new(); - let recorder = - create_replacement_recorder(store, source_meta, rollout_id, history_base).await?; + let recorder = create_replacement_recorder( + store, + source_meta, + rollout_id, + history_base, + forked_from_ordinal_exclusive, + ) + .await?; let replacement_path = recorder.rollout_path().to_path_buf(); recorder.persist().await.map_err(thread_store_io_error)?; recorder.shutdown().await.map_err(thread_store_io_error)?; @@ -127,6 +140,7 @@ async fn create_replacement_recorder( source_meta: codex_rollout::SessionMeta, rollout_id: ThreadId, history_base: Option, + forked_from_ordinal_exclusive: Option, ) -> ThreadStoreResult { let config = RolloutConfig { codex_home: store.config.codex_home.clone(), @@ -154,6 +168,7 @@ async fn create_replacement_recorder( .with_multi_agent_version(source_meta.multi_agent_version) .with_history_mode(ThreadHistoryMode::Paginated) .with_history_base(history_base) + .with_forked_from_ordinal_exclusive(forked_from_ordinal_exclusive) .with_subagent_history_start_ordinal(source_meta.subagent_history_start_ordinal); if let Some(context_window) = source_meta.context_window { params = params.with_initial_window_id(context_window.window_id);