From 528ca20a7688cf77010845bc45d557a5c2266f67 Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Tue, 4 Aug 2026 05:35:57 -0400 Subject: [PATCH 1/4] bridge: restore fork lineage on cold thread lists --- apps/ios/scripts/sync-codex.sh | 1 + patches/codex/thread-list-fork-lineage.patch | 88 ++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 patches/codex/thread-list-fork-lineage.patch diff --git a/apps/ios/scripts/sync-codex.sh b/apps/ios/scripts/sync-codex.sh index b3775c834..a24743585 100755 --- a/apps/ios/scripts/sync-codex.sh +++ b/apps/ios/scripts/sync-codex.sh @@ -9,6 +9,7 @@ PATCH_FILES=( "$REPO_DIR/patches/codex/ios-exec-hook.patch" "$REPO_DIR/patches/codex/mobile-code-mode-stub.patch" "$REPO_DIR/patches/codex/thread-read-permissions.patch" + "$REPO_DIR/patches/codex/thread-list-fork-lineage.patch" "$REPO_DIR/patches/codex/mobile-shell-snapshot-timeout.patch" "$REPO_DIR/patches/codex/remote-app-server-websocket-cap.patch" "$REPO_DIR/patches/codex/absolute-path-cross-platform.patch" diff --git a/patches/codex/thread-list-fork-lineage.patch b/patches/codex/thread-list-fork-lineage.patch new file mode 100644 index 000000000..c5d5275d8 --- /dev/null +++ b/patches/codex/thread-list-fork-lineage.patch @@ -0,0 +1,88 @@ +diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs +index ede9e9e..ead8b7f 100644 +--- a/codex-rs/thread-store/src/local/list_threads.rs ++++ b/codex-rs/thread-store/src/local/list_threads.rs +@@ -75,6 +75,25 @@ pub(super) async fn list_threads( + }) + .collect::>(); + ++ // The lightweight rollout-list projection does not carry fork lineage, ++ // while `read_thread` does. Restore the immutable parent id from the ++ // canonical session metadata so a cold `thread/list` can rediscover ++ // persistent forks without loading their histories. Keep state-DB-only ++ // requests free of filesystem reads as their contract requires. ++ if !params.use_state_db_only { ++ for thread in &mut items { ++ let Some(rollout_path) = thread.rollout_path.as_ref() else { ++ continue; ++ }; ++ let Ok(meta_line) = codex_rollout::read_session_meta_line(rollout_path).await else { ++ continue; ++ }; ++ if meta_line.meta.id == thread.thread_id { ++ thread.forked_from_id = meta_line.meta.forked_from_id; ++ } ++ } ++ } ++ + let thread_ids = items + .iter() + .map(|thread| thread.thread_id) +@@ -199,6 +218,7 @@ mod tests { + use crate::local::test_support::write_archived_session_file; + use crate::local::test_support::write_session_file; + use crate::local::test_support::write_session_file_with; ++ use crate::local::test_support::write_session_file_with_fork; + + #[tokio::test] + async fn list_threads_uses_default_provider_when_rollout_omits_provider() { +@@ -234,6 +254,49 @@ mod tests { + assert_eq!(page.items[0].model_provider, "test-provider"); + } + ++ #[tokio::test] ++ async fn cold_list_threads_preserves_fork_lineage_from_rollout() { ++ let home = TempDir::new().expect("temp dir"); ++ let parent_id = Uuid::from_u128(103); ++ let fork_id = Uuid::from_u128(104); ++ write_session_file_with_fork( ++ home.path(), ++ home.path().join("sessions/2025/01/03"), ++ "2025-01-03T12-00-00", ++ fork_id, ++ "Forked user message", ++ Some("test-provider"), ++ Some(parent_id), ++ ) ++ .expect("fork rollout"); ++ ++ // A fresh store has no warm in-memory fork response to preserve. The ++ // list response must recover lineage from the persisted rollout. ++ let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None); ++ let page = store ++ .list_threads(ListThreadsParams { ++ page_size: 10, ++ cursor: None, ++ sort_key: ThreadSortKey::CreatedAt, ++ sort_direction: SortDirection::Desc, ++ allowed_sources: Vec::new(), ++ model_providers: None, ++ cwd_filters: None, ++ archived: false, ++ search_term: None, ++ use_state_db_only: false, ++ }) ++ .await ++ .expect("cold thread listing"); ++ ++ assert_eq!(page.items.len(), 1); ++ assert_eq!(page.items[0].thread_id.to_string(), fork_id.to_string()); ++ assert_eq!( ++ page.items[0].forked_from_id.map(|id| id.to_string()), ++ Some(parent_id.to_string()) ++ ); ++ } ++ + #[tokio::test] + async fn list_threads_preserves_sqlite_title_search_results() { + let home = TempDir::new().expect("temp dir"); From 411e6a265928cb3bb2aa85d3333f2617d7e67789 Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Mon, 14 Sep 2026 09:25:24 -0400 Subject: [PATCH 2/4] bridge: carry fork lineage through the rollout list projection --- patches/codex/thread-list-fork-lineage.patch | 249 ++++++++++++++++--- 1 file changed, 218 insertions(+), 31 deletions(-) diff --git a/patches/codex/thread-list-fork-lineage.patch b/patches/codex/thread-list-fork-lineage.patch index c5d5275d8..b3a395d35 100644 --- a/patches/codex/thread-list-fork-lineage.patch +++ b/patches/codex/thread-list-fork-lineage.patch @@ -1,45 +1,232 @@ +diff --git a/codex-rs/rollout/src/list.rs b/codex-rs/rollout/src/list.rs +index d4f067789..d3119771f 100644 +--- a/codex-rs/rollout/src/list.rs ++++ b/codex-rs/rollout/src/list.rs +@@ -48,6 +48,9 @@ pub struct ThreadItem { + pub path: PathBuf, + /// Thread ID from session metadata. + pub thread_id: Option, ++ /// Immutable parent thread id from session metadata, when this rollout ++ /// is a fork. ++ pub forked_from_id: Option, + /// First user message captured for this thread, if any. + pub first_user_message: Option, + /// Best available user-facing preview for discovery and list display. +@@ -88,6 +91,7 @@ pub type ConversationsPage = ThreadsPage; + struct HeadTailSummary { + saw_session_meta: bool, + thread_id: Option, ++ forked_from_id: Option, + first_user_message: Option, + preview: Option, + cwd: Option, +@@ -771,6 +775,7 @@ async fn build_thread_item( + if summary.saw_session_meta && summary.preview.is_some() { + let HeadTailSummary { + thread_id, ++ forked_from_id, + first_user_message, + preview, + cwd, +@@ -792,6 +797,7 @@ async fn build_thread_item( + return Some(ThreadItem { + path, + thread_id, ++ forked_from_id, + first_user_message, + preview, + cwd, +@@ -1105,6 +1111,7 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result ThreadI + ThreadItem { + path: item.rollout_path, + thread_id: Some(item.id), ++ forked_from_id: None, + first_user_message: item.first_user_message, + preview: item.preview, + cwd: Some(item.cwd), +diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs +index 8199f290d..7030d753d 100644 +--- a/codex-rs/rollout/src/recorder_tests.rs ++++ b/codex-rs/rollout/src/recorder_tests.rs +@@ -961,9 +961,11 @@ async fn list_threads_metadata_filter_overlays_state_db_list_metadata() -> std:: + fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fields() { + let filesystem_thread_id = ThreadId::new(); + let state_thread_id = ThreadId::new(); ++ let state_fork_thread_id = ThreadId::new(); + let filesystem_path = PathBuf::from("/tmp/filesystem-rollout.jsonl"); + let state_path = PathBuf::from("/tmp/state-rollout.jsonl"); + let mut item = ThreadItem { ++ forked_from_id: None, + path: filesystem_path.clone(), + thread_id: Some(filesystem_thread_id), + first_user_message: Some("filesystem message".to_string()), +@@ -981,6 +983,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi + updated_at: None, + }; + let state_item = ThreadItem { ++ forked_from_id: Some(state_fork_thread_id), + path: state_path, + thread_id: Some(state_thread_id), + first_user_message: Some("state message".to_string()), +@@ -1021,6 +1024,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi + assert_eq!(item.cli_version.as_deref(), Some("state-version")); + assert_eq!(item.created_at.as_deref(), Some("2025-01-03T16:00:00Z")); + assert_eq!(item.updated_at.as_deref(), Some("2025-01-03T16:01:02.003Z")); ++ assert_eq!(item.forked_from_id, Some(state_fork_thread_id)); + } + + #[tokio::test] +diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs +index bcd395d82..e640ef1ed 100644 +--- a/codex-rs/rollout/src/tests.rs ++++ b/codex-rs/rollout/src/tests.rs +@@ -724,6 +724,7 @@ async fn test_list_conversations_latest_first() { + let expected = ThreadsPage { + items: vec![ + ThreadItem { ++ forked_from_id: None, + path: p1, + thread_id: Some(thread_id_from_uuid(u3)), + first_user_message: Some("Hello from user".to_string()), +@@ -741,6 +742,7 @@ async fn test_list_conversations_latest_first() { + updated_at: updated_times.first().cloned().flatten(), + }, + ThreadItem { ++ forked_from_id: None, + path: p2, + thread_id: Some(thread_id_from_uuid(u2)), + first_user_message: Some("Hello from user".to_string()), +@@ -758,6 +760,7 @@ async fn test_list_conversations_latest_first() { + updated_at: updated_times.get(1).cloned().flatten(), + }, + ThreadItem { ++ forked_from_id: None, + path: p3, + thread_id: Some(thread_id_from_uuid(u1)), + first_user_message: Some("Hello from user".to_string()), +@@ -868,6 +871,7 @@ async fn test_pagination_cursor() { + let expected_page1 = ThreadsPage { + items: vec![ + ThreadItem { ++ forked_from_id: None, + path: p5, + thread_id: Some(thread_id_from_uuid(u5)), + first_user_message: Some("Hello from user".to_string()), +@@ -885,6 +889,7 @@ async fn test_pagination_cursor() { + updated_at: updated_page1.first().cloned().flatten(), + }, + ThreadItem { ++ forked_from_id: None, + path: p4, + thread_id: Some(thread_id_from_uuid(u4)), + first_user_message: Some("Hello from user".to_string()), +@@ -938,6 +943,7 @@ async fn test_pagination_cursor() { + let expected_page2 = ThreadsPage { + items: vec![ + ThreadItem { ++ forked_from_id: None, + path: p3, + thread_id: Some(thread_id_from_uuid(u3)), + first_user_message: Some("Hello from user".to_string()), +@@ -955,6 +961,7 @@ async fn test_pagination_cursor() { + updated_at: updated_page2.first().cloned().flatten(), + }, + ThreadItem { ++ forked_from_id: None, + path: p2, + thread_id: Some(thread_id_from_uuid(u2)), + first_user_message: Some("Hello from user".to_string()), +@@ -1000,6 +1007,7 @@ async fn test_pagination_cursor() { + page3.items.iter().map(|i| i.updated_at.clone()).collect(); + let expected_page3 = ThreadsPage { + items: vec![ThreadItem { ++ forked_from_id: None, + path: p1, + thread_id: Some(thread_id_from_uuid(u1)), + first_user_message: Some("Hello from user".to_string()), +@@ -1170,6 +1178,7 @@ async fn test_get_thread_contents() { + .join(format!("rollout-2025-04-01T10-30-00-{uuid}.jsonl")); + let expected_page = ThreadsPage { + items: vec![ThreadItem { ++ forked_from_id: None, + path: expected_path, + thread_id: Some(thread_id_from_uuid(uuid)), + first_user_message: Some("Hello from user".to_string()), +@@ -1520,6 +1529,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { + let expected_page1 = ThreadsPage { + items: vec![ + ThreadItem { ++ forked_from_id: None, + path: p3, + thread_id: Some(thread_id_from_uuid(u3)), + first_user_message: Some("Hello from user".to_string()), +@@ -1537,6 +1547,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() { + updated_at: updated_page1.first().cloned().flatten(), + }, + ThreadItem { ++ forked_from_id: None, + path: p2, + thread_id: Some(thread_id_from_uuid(u2)), + first_user_message: Some("Hello from user".to_string()), +diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs +index bfdc94fbf..4e8ed287e 100644 +--- a/codex-rs/thread-store/src/local/helpers.rs ++++ b/codex-rs/thread-store/src/local/helpers.rs +@@ -119,7 +119,7 @@ pub(super) fn stored_thread_from_rollout_item( + Some(StoredThread { + thread_id, + rollout_path: Some(item.path), +- forked_from_id: None, ++ forked_from_id: item.forked_from_id, + preview, + name: None, + model_provider: item diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs -index ede9e9e..ead8b7f 100644 +index ede9e9e9c..9b8490e71 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs -@@ -75,6 +75,25 @@ pub(super) async fn list_threads( - }) - .collect::>(); - -+ // The lightweight rollout-list projection does not carry fork lineage, -+ // while `read_thread` does. Restore the immutable parent id from the -+ // canonical session metadata so a cold `thread/list` can rediscover -+ // persistent forks without loading their histories. Keep state-DB-only -+ // requests free of filesystem reads as their contract requires. -+ if !params.use_state_db_only { -+ for thread in &mut items { -+ let Some(rollout_path) = thread.rollout_path.as_ref() else { -+ continue; -+ }; -+ let Ok(meta_line) = codex_rollout::read_session_meta_line(rollout_path).await else { -+ continue; -+ }; -+ if meta_line.meta.id == thread.thread_id { -+ thread.forked_from_id = meta_line.meta.forked_from_id; -+ } -+ } -+ } -+ - let thread_ids = items - .iter() - .map(|thread| thread.thread_id) -@@ -199,6 +218,7 @@ mod tests { +@@ -199,6 +199,7 @@ mod tests { use crate::local::test_support::write_archived_session_file; use crate::local::test_support::write_session_file; use crate::local::test_support::write_session_file_with; + use crate::local::test_support::write_session_file_with_fork; - + #[tokio::test] async fn list_threads_uses_default_provider_when_rollout_omits_provider() { -@@ -234,6 +254,49 @@ mod tests { +@@ -234,6 +235,49 @@ mod tests { assert_eq!(page.items[0].model_provider, "test-provider"); } - + + #[tokio::test] + async fn cold_list_threads_preserves_fork_lineage_from_rollout() { + let home = TempDir::new().expect("temp dir"); From 2b1e88111bf11d47131c5a13d1213403ae9f3f58 Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Mon, 14 Sep 2026 09:26:06 -0400 Subject: [PATCH 3/4] ci: test the patched codex-thread-store crate --- .github/workflows/mobile-ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml index 641c4318d..8484a4c0f 100644 --- a/.github/workflows/mobile-ci.yml +++ b/.github/workflows/mobile-ci.yml @@ -118,7 +118,12 @@ jobs: - name: Shared Rust tests env: CARGO_INCREMENTAL: "0" - run: cargo test --manifest-path shared/rust-bridge/Cargo.toml -p codex-mobile-client -p codex-slingshot + run: | + cargo test --manifest-path shared/rust-bridge/Cargo.toml -p codex-mobile-client -p codex-slingshot + # The codex submodule is checked out pristine on cache hits, so apply + # the patch set before testing patched submodule crates. + ./apps/ios/scripts/sync-codex.sh --recorded-gitlink + cargo test --manifest-path shared/third_party/codex/codex-rs/Cargo.toml -p codex-thread-store - name: Release script tests run: | From 1e4239c963a460371ad1211d102617fcc6a16cd2 Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Mon, 14 Sep 2026 09:26:49 -0400 Subject: [PATCH 4/4] test: assert non-fork threads keep empty lineage in cold lists --- patches/codex/thread-list-fork-lineage.patch | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/patches/codex/thread-list-fork-lineage.patch b/patches/codex/thread-list-fork-lineage.patch index b3a395d35..c562c1b28 100644 --- a/patches/codex/thread-list-fork-lineage.patch +++ b/patches/codex/thread-list-fork-lineage.patch @@ -212,7 +212,7 @@ index bfdc94fbf..4e8ed287e 100644 name: None, model_provider: item diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs -index ede9e9e9c..9b8490e71 100644 +index ede9e9e9c..d7b68b844 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -199,6 +199,7 @@ mod tests { @@ -223,7 +223,7 @@ index ede9e9e9c..9b8490e71 100644 #[tokio::test] async fn list_threads_uses_default_provider_when_rollout_omits_provider() { -@@ -234,6 +235,49 @@ mod tests { +@@ -234,6 +235,58 @@ mod tests { assert_eq!(page.items[0].model_provider, "test-provider"); } @@ -232,6 +232,7 @@ index ede9e9e9c..9b8490e71 100644 + let home = TempDir::new().expect("temp dir"); + let parent_id = Uuid::from_u128(103); + let fork_id = Uuid::from_u128(104); ++ let plain_id = Uuid::from_u128(105); + write_session_file_with_fork( + home.path(), + home.path().join("sessions/2025/01/03"), @@ -242,6 +243,8 @@ index ede9e9e9c..9b8490e71 100644 + Some(parent_id), + ) + .expect("fork rollout"); ++ write_session_file(home.path(), "2025-01-02T12-00-00", plain_id) ++ .expect("plain rollout"); + + // A fresh store has no warm in-memory fork response to preserve. The + // list response must recover lineage from the persisted rollout. @@ -262,12 +265,18 @@ index ede9e9e9c..9b8490e71 100644 + .await + .expect("cold thread listing"); + -+ assert_eq!(page.items.len(), 1); ++ assert_eq!(page.items.len(), 2); + assert_eq!(page.items[0].thread_id.to_string(), fork_id.to_string()); + assert_eq!( + page.items[0].forked_from_id.map(|id| id.to_string()), + Some(parent_id.to_string()) + ); ++ let plain = page ++ .items ++ .iter() ++ .find(|item| item.thread_id.to_string() == plain_id.to_string()) ++ .expect("plain thread listed"); ++ assert!(plain.forked_from_id.is_none()); + } + #[tokio::test]