You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Codex: record fork lineage from session_meta (forked_from_id, thread_spawn.parent_thread_id, thread_source) and gate replayed parent history in forked rollouts #210
Status against main @ 338ce5f (2026-09-21): still open, and more specific now that #170 merged as PR #196. crates/ai-hist/src/continuity.rs:316-395 (scan_codex_rollout) reads the opening session_meta for sourceSessionId, forkSessionId and continuedFromSessionId (Claude's field names, checked in both spellings) and docs/architecture.md:321-324 concludes "Codex records continuity only when a producer writes those explicit fields ... A plain codex resume opens with a fresh payload.id". The fields Codex actually writes for a fork are different, and none of them are read: forked_from_id, thread_source, and source.subagent.thread_spawn.parent_thread_id. There is also no replay gate: git grep 'forked_from\|replay' crates/ai-hist/src/ingest.rs finds nothing relevant.
Summary
tokscale (commit d8fd670, crates/tokscale-core/src/sessions/codex.rs) has characterized Codex forks in production. A forked rollout names its parent on session_meta, and it replays the parent's history (the parent's session_meta and user_message events included) before its own turns begin. Today relayhistory treats a fork as an unrelated session and re-ingests the replayed parent prompts and events under the child id.
What the rollout carries (tokscale CodexPayload, codex.rs:55-82)
pubstructCodexPayload{pubid:Option<String>,pubforked_from_id:Option<String>,// session_meta: the thread this one was forked frompuboriginator:Option<String>,// session_meta: clientInfo.name of the creating clientpubthread_source:Option<String>,// "user" = human fork (VS Code "fork conversation"); "subagent"; "guardian_review" (0.150+)pubsource:Option<Value>,// object form may carry subagent.thread_spawn.parent_thread_idpubstarted_at:Option<i64>,// task_started: unix seconds (legacy UUIDv4 turns' only ordering signal)pubturn_id:Option<String>,
...
}
Evidence that these are real wire fields, from tokscale's own fixtures:
codex.rs:2030 {"type":"session_meta","payload":{"id":"child","forked_from_id":"parent","originator":"Codex Desktop","source":"vscode","model_provider":"openai"}}
codex.rs:2621 {"type":"session_meta","payload":{"id":"guardian-thread","parent_thread_id":"parent-thread","source":{"subagent":{"other":"guardian"}},"thread_source":"guardian_review",...}}
codex.rs:2632 same thread with "thread_source":"subagent" (pre-0.150 builds)
Parent resolution (codex.rs:591-596, :988-995):
let forked_from_id = payload.forked_from_id.as_deref().filter(|id| !id.is_empty()).or_else(|| forked_from_id_from_source(payload.source.as_ref()));fnforked_from_id_from_source(source:Option<&Value>) -> Option<&str>{
source?.get("subagent")?.get("thread_spawn")?.get("parent_thread_id")?.as_str().filter(|id| !id.is_empty())}
The second path is the same thread_spawn.parent_thread_id relayhistory already reads for delegation in codex_parent_thread_id (crates/ai-hist/src/ingest.rs:3535): a subagent spawn is also a fork of the parent's context in Codex; a human fork carries forked_from_id + thread_source: "user".
Guardian: codex_is_subagent (ingest.rs:3578) checks thread_source == "subagent" or source.subagent with an explicit parent. The existing tests (ingest.rs:23504, :24089) only use thread_source: "subagent"; add the guardian_review spelling and confirm classification.
The replay trap
codex.rs:576-580: "A forked child replays its parent's session_meta too, and that copy is skipped by the replay gate." codex.rs:664-672: "Forked-child replays of the parent prompt arrive before turn_context and are skipped ... so they never reach here."
Mechanism (forked_child_turn_starts_own_session, codex.rs:997-1085): after a session_meta with a fork parent, every record is replay until the first turn that provably belongs to the child:
Thread and turn ids are UUIDv7; the first 12 hex chars are a millisecond timestamp. A turn whose prefix is greater than the child thread id's is the child's; less is replay; equal is decided by task_started for subagent forks (their own turns emit it, replayed ones do not) and counts as the child's for human forks (which never emit task_started).
Legacy UUIDv4 turn ids fall back to task_started.started_at >= child_fork_second.
The parent's cumulative token_count totals arriving in the replay are remembered as a baseline (remember_forked_child_inherited_baseline, :1241) so the child's first delta is not the whole parent context.
What this means on main:
ingest_codex_rollout / ingest_codex_rollout_incremental (ingest.rs:4064, :4090) insert a history row per accepted human message and events per record, so a fork duplicates the parent's prompts under the child in search/recent, and the parent's token_count snapshots seed the child's baseline (docs/usage-accounting.md "request-span" and baseline numbering), attributing the parent's whole context to the child's first request.
read_codex_session_meta (ingest.rs:3683) and scan_codex_rollout read only the first line, so the replayed parent session_meta later in the file is at least not mistaken for the child's identity.
Proposed change
scan_codex_rollout (continuity.rs:325): also read forked_from_id and source.subagent.thread_spawn.parent_thread_id into explicit_fork_targets, and thread_source into the evidence, with evidence_ref naming the field (REF_FORKED_FROM_ID, REF_THREAD_SPAWN_PARENT). Reconciliation then writes the fork row (RELATIONSHIP_FORK, relationship_graph.rs:36) through the existing pending/unresolved machinery; a subagent fork keeps its delegation row and gains the fork edge. Human forks (thread_source: "user") stay top-level catalog sessions.
Update docs/architecture.md:321-324: Codex does record forks; only codex resume is unobservable.
Replay gate in ingest_codex_rollout (and the incremental path): when the file's own session_meta names a fork parent, skip history, session_events and token_count differencing until the first child-owned turn per the UUIDv7 prefix / task_started / started_at rules above; seed prev_totals from the inherited total_token_usage; write one session_markers row (kind = "fork_replay_boundary") where the gate opens. Store forked_from_id/thread_source among the raw facts ([G1] Capture per-message raw facts burn needs: requestId, stop_reason, agent version, sidechain/meta flags, Codex turn ids #164 landed the columns).
Parent rollout P (2 human turns) and child C (forked_from_id: P, thread_source: "user", replay of P's meta + 2 turns, then one own turn): after sync --local, history has 3 rows (2 for P, 1 for C); C's events contain only its own turn; C's per-request usage equals final_total - inherited_baseline; session_relationships has one fork row P -> C with evidence_ref = "forked_from_id"; hydrating C before P yields RELATIONSHIP_CONTINUITY_UNRESOLVED and resolves when P arrives.
Subagent fork with an equal-millisecond turn: attributed to the child only because of task_started.
guardian_review rollout with parent_thread_id: hidden from the root catalog like other subagents.
Summary
tokscale (commit
d8fd670,crates/tokscale-core/src/sessions/codex.rs) has characterized Codex forks in production. A forked rollout names its parent onsession_meta, and it replays the parent's history (the parent'ssession_metaanduser_messageevents included) before its own turns begin. Today relayhistory treats a fork as an unrelated session and re-ingests the replayed parent prompts and events under the child id.What the rollout carries (tokscale
CodexPayload,codex.rs:55-82)Evidence that these are real wire fields, from tokscale's own fixtures:
Parent resolution (
codex.rs:591-596,:988-995):The second path is the same
thread_spawn.parent_thread_idrelayhistory already reads for delegation incodex_parent_thread_id(crates/ai-hist/src/ingest.rs:3535): a subagent spawn is also a fork of the parent's context in Codex; a human fork carriesforked_from_id+thread_source: "user".Guardian:
codex_is_subagent(ingest.rs:3578) checksthread_source == "subagent"orsource.subagentwith an explicit parent. The existing tests (ingest.rs:23504,:24089) only usethread_source: "subagent"; add theguardian_reviewspelling and confirm classification.The replay trap
codex.rs:576-580: "A forked child replays its parent'ssession_metatoo, and that copy is skipped by the replay gate."codex.rs:664-672: "Forked-child replays of the parent prompt arrive beforeturn_contextand are skipped ... so they never reach here."Mechanism (
forked_child_turn_starts_own_session,codex.rs:997-1085): after asession_metawith a fork parent, every record is replay until the first turn that provably belongs to the child:task_startedfor subagent forks (their own turns emit it, replayed ones do not) and counts as the child's for human forks (which never emittask_started).task_started.started_at >= child_fork_second.token_counttotals arriving in the replay are remembered as a baseline (remember_forked_child_inherited_baseline,:1241) so the child's first delta is not the whole parent context.What this means on main:
ingest_codex_rollout/ingest_codex_rollout_incremental(ingest.rs:4064,:4090) insert ahistoryrow per accepted human message and events per record, so a fork duplicates the parent's prompts under the child insearch/recent, and the parent'stoken_countsnapshots seed the child's baseline (docs/usage-accounting.md"request-span" and baseline numbering), attributing the parent's whole context to the child's first request.read_codex_session_meta(ingest.rs:3683) andscan_codex_rolloutread only the first line, so the replayed parentsession_metalater in the file is at least not mistaken for the child's identity.Proposed change
scan_codex_rollout(continuity.rs:325): also readforked_from_idandsource.subagent.thread_spawn.parent_thread_idintoexplicit_fork_targets, andthread_sourceinto the evidence, withevidence_refnaming the field (REF_FORKED_FROM_ID,REF_THREAD_SPAWN_PARENT). Reconciliation then writes theforkrow (RELATIONSHIP_FORK,relationship_graph.rs:36) through the existing pending/unresolved machinery; a subagent fork keeps its delegation row and gains the fork edge. Human forks (thread_source: "user") stay top-level catalog sessions.docs/architecture.md:321-324: Codex does record forks; onlycodex resumeis unobservable.ingest_codex_rollout(and the incremental path): when the file's ownsession_metanames a fork parent, skiphistory,session_eventsandtoken_countdifferencing until the first child-owned turn per the UUIDv7 prefix /task_started/started_atrules above; seedprev_totalsfrom the inheritedtotal_token_usage; write onesession_markersrow (kind = "fork_replay_boundary") where the gate opens. Storeforked_from_id/thread_sourceamong the raw facts ([G1] Capture per-message raw facts burn needs: requestId, stop_reason, agent version, sidechain/meta flags, Codex turn ids #164 landed the columns).Acceptance
P(2 human turns) and childC(forked_from_id: P,thread_source: "user", replay ofP's meta + 2 turns, then one own turn): aftersync --local,historyhas 3 rows (2 for P, 1 for C);C's events contain only its own turn;C's per-request usage equalsfinal_total - inherited_baseline;session_relationshipshas oneforkrowP -> Cwithevidence_ref = "forked_from_id"; hydratingCbeforePyieldsRELATIONSHIP_CONTINUITY_UNRESOLVEDand resolves whenParrives.task_started.guardian_reviewrollout withparent_thread_id: hidden from the root catalog like other subagents.Related