Skip to content

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

Description

@willwashburn

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)

pub struct CodexPayload {
    pub id: Option<String>,
    pub forked_from_id: Option<String>,   // session_meta: the thread this one was forked from
    pub originator: Option<String>,       // session_meta: clientInfo.name of the creating client
    pub thread_source: Option<String>,    // "user" = human fork (VS Code "fork conversation"); "subagent"; "guardian_review" (0.150+)
    pub source: Option<Value>,            // object form may carry subagent.thread_spawn.parent_thread_id
    pub started_at: Option<i64>,          // task_started: unix seconds (legacy UUIDv4 turns' only ordering signal)
    pub turn_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()));
fn forked_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

  1. 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.
  2. Update docs/architecture.md:321-324: Codex does record forks; only codex resume is unobservable.
  3. 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).
  4. Fixtures ([G1] Checked-in harness fixture corpus and parser characterization tests (port burn's 40 fixtures) #163): a human fork and a subagent fork, each with a replayed parent and a child-owned turn sharing the fork millisecond.

Acceptance

  • 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.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions