Skip to content

Add a detached effective-context fork for oversized Desktop threads #38091

Description

@ai-jz

What variant of Codex are you using?

Codex App on macOS. The proposed primitive belongs in app-server so the App, CLI, and IDE clients can reuse the same semantics.

What feature would you like to see?

Summary

Add a detached effective-context fork for very long threads.

After a manual or automatic compaction has completed, the user should be able to continue in a new task that inherits the source thread's latest effective model context, but does not inherit or expose the source thread's complete conversation history.

This is a performance and working-set feature, not a request for a new handoff summary or different compaction quality.

Motivation

Very long, tool-heavy Codex App threads can become progressively expensive for the Desktop renderer and app-server to hydrate, parse, serialize, and retain. This can remain a problem after model-context compaction because the persisted/UI-visible transcript is still large even though the model now works from a bounded compacted state.

Existing choices do not provide the desired boundary:

  • A normal fork inherits the conversation history. Paginated reference-backed forks avoid copying the entire parent JSONL, but their logical history still traverses the parent lineage.
  • A new task has a small transcript, but loses the already-computed effective model context.
  • A generated HANDOFF.md or handoff prompt performs another lossy summarization step and is aimed at continuity quality rather than local history hydration cost.

The desired operation is conceptually a shallow/compact fork:

source full transcript
        |
        | latest usable compacted replacement history + surviving suffix
        v
new task with bounded model context and no inherited visible turn history

Proposed semantics

An illustrative app-server request could extend thread/fork with an enum such as:

{
  "method": "thread/fork",
  "params": {
    "threadId": "<source-thread-id>",
    "historyScope": "effectiveContext",
    "excludeTurns": true,
    "deferGoalContinuation": true
  }
}

The exact API name is not important. The important behavior is:

  1. Require the source thread to be idle and use the latest successfully persisted effective model context.
  2. Seed the child with the latest usable Compacted.replacement_history plus the surviving post-compaction suffix needed for the next model request.
  3. Persist that bounded snapshot in the child instead of copying the complete source rollout or retaining a history_base that exposes the parent turns.
  4. Preserve normal fork metadata behavior: forkedFromId, cwd/workspace roots, current configuration and instructions, permissions, environments, and optionally the current Goal.
  5. Leave the source thread unchanged and available for audit.
  6. After switching the App to the child, stop hydrating/subscribing to the source so its renderer/app-server state can be released.
  7. For legacy histories, either migrate to paginated history first or return a clear unsupported result. Do not silently fall back to a full-history copy/read for this mode.

The App could expose this as Continue in compact fork or Continue in a lightweight task after a successful compaction. Automatic rollover is not required for the initial version.

Acceptance criteria

  • The child's first model request receives the same effective conversation state as the source would receive immediately after compaction, apart from intentionally refreshed thread-local context.
  • The child rollout size is proportional to the effective model context, not the full source rollout.
  • thread/turns/list on the child does not traverse or return source turns.
  • Cold resume of the child preserves the same effective context.
  • The source rollout is not rewritten or deleted.
  • Navigating to the child does not require the App to hydrate the source transcript.

Additional information

This appears close to existing app-server building blocks:

  • thread/fork already supports paginated reference-backed forks and bounded model-context reconstruction: Support paginated thread forks #35220
  • Legacy-to-paginated rollout migration has landed: Add legacy rollout migration to paginated history #37175
  • CLI/background migration tooling has landed: Add rollout migration tooling and background migration #37348
  • Current load_latest_model_context reverse-scans paginated rollouts to a usable replacement-history checkpoint:
    /// Loads rollout items needed to reconstruct the latest model-visible context.
    ///
    /// Plain paginated JSONL rollouts use a reverse scan. When it finds both a usable replacement-
    /// history checkpoint and the completed user-turn context needed for resume metadata, the returned
    /// replay starts with the canonical head `SessionMeta` followed by that newest suffix. When no
    /// bounded cutoff is available, the scan continues to the beginning and returns the complete
    /// replay it already accumulated.
    ///
    /// Legacy and compressed rollout shapes keep the existing full-history path.
    pub(super) async fn load_latest_model_context(
    store: &LocalThreadStore,
    params: LoadThreadHistoryParams,
    ) -> ThreadStoreResult<StoredModelContext> {
    let path = read_thread::resolve_rollout_path(store, params.thread_id, params.include_archived)
    .await?
    .ok_or_else(|| ThreadStoreError::InvalidRequest {
    message: format!("no rollout found for thread id {}", params.thread_id),
    })?;
    let session_meta = codex_rollout::read_session_meta_line(path.as_path())
    .await
    .map_err(|err| ThreadStoreError::Internal {
    message: format!("failed to read session metadata {}: {err}", path.display()),
    })?;
    if session_meta.meta.id != params.thread_id {
    return Err(ThreadStoreError::InvalidRequest {
    message: format!(
    "rollout at {} belongs to thread {}, not {}",
    path.display(),
    session_meta.meta.id,
    params.thread_id
    ),
    });
    }
    let items = if matches!(session_meta.meta.history_mode, ThreadHistoryMode::Paginated)
    && !path
    .file_name()
    .and_then(|file_name| file_name.to_str())
    .is_some_and(|file_name| file_name.ends_with(".jsonl.zst"))
    {
    let lineage = store.resolve_rollout_lineage(params.thread_id).await?;
    scan_model_context_from_lineage(lineage, session_meta).await?
    } else {
    read_thread::load_history_items(path.as_path()).await?
  • Current fork processing already obtains prepared_fork.model_context, but persists paginated forks as parent-history references:
    let paginated_source = matches!(source_thread.history_mode, ThreadHistoryMode::Paginated);
    if last_turn_id.is_some() && before_turn_id.is_some() {
    return Err(invalid_request(
    "`beforeTurnId` cannot be combined with `lastTurnId`",
    ));
    }
    if ephemeral && defer_goal_continuation {
    return Err(invalid_request(
    "`deferGoalContinuation` cannot be combined with `ephemeral`",
    ));
    }
    if paginated_source && ephemeral && include_turns {
    return Err(invalid_request(
    "ephemeral paginated thread/fork requires `excludeTurns: true`",
    ));
    }
    let source_thread_id = source_thread.thread_id;
    let source_thread_name = source_thread
    .name
    .as_deref()
    .and_then(codex_core::util::normalize_thread_name);
    let prepared_fork = if paginated_source {
    let boundary = match (last_turn_id.as_deref(), before_turn_id.as_deref()) {
    (Some(turn_id), None) => {
    codex_thread_store::ForkBoundary::ThroughTurn(turn_id.to_string())
    }
    (None, Some(turn_id)) => {
    codex_thread_store::ForkBoundary::BeforeTurn(turn_id.to_string())
    }
    (None, None) => codex_thread_store::ForkBoundary::Latest,
    (Some(_), Some(_)) => unreachable!("fork boundaries are mutually exclusive"),
    };
    Some(
    self.thread_store
    .prepare_fork(codex_thread_store::PrepareForkParams {
    thread_id: source_thread_id,
    boundary,
    })
    .await
    .map_err(|err| match err {
    ThreadStoreError::InvalidRequest { message } => invalid_request(message),
    ThreadStoreError::ThreadNotFound { thread_id } => {
    invalid_request(format!("no rollout found for thread id {thread_id}"))
    }
    ThreadStoreError::Unsupported { .. } => {
    method_not_found("paginated_threads is not supported yet")
    }
    err => internal_error(format!("failed to prepare paginated fork: {err}")),
    })?,
    )
    } else {
    None
    };
    let source_history_items = if let Some(prepared_fork) = prepared_fork.as_ref() {
    Arc::clone(&prepared_fork.model_context)
    } else {
    let mut source_thread = self
    .read_stored_thread_for_resume(
    &thread_id,
    path.as_ref(),
    /*include_history*/ true,
    )
    .await?;
    Arc::new(
    source_thread
    .history
    .take()
    .map(|history| history.items)
    .ok_or_else(|| {
    internal_error(format!(
    "thread {source_thread_id} did not include persisted history"
    ))
    })?,
    )
    };

Related issues cover different parts of the problem:

The repository accepts external PRs by invitation only. If this direction aligns with the intended thread-storage architecture, would the maintainers consider inviting a focused backend-only PR for the experimental app-server primitive? The Desktop button/navigation could remain an internal follow-up.

Activity

  1. added
    enhancementNew feature or request
    appIssues related to the Codex desktop app
    app-serverIssues involving app server protocol or interfaces
    contextIssues related to context management (including compaction)
    on Aug 12, 2026
  2. boombx403-byte commented on Aug 18, 2026

    @boombx403-byte

    Hi @ai-jz, that rollout size is valuable stress evidence. Codex Rescue Alpha5 scans large local session histories with bounded memory and produces aggregate size, media, and compaction diagnostics without loading the whole file into RAM or dumping full payloads.

    If you still have an affected closed rollout, you can test it locally:

    pip install codex-rescue==0.1.0a5
    codex-rescue doctor <path-to-rollout.jsonl>

    No raw session upload is needed; sanitized aggregate output is sufficient.

  3. Robird commented on Aug 29, 2026

    @Robird

    https://github.com/Robird/codex-rollout-shrinker
    codex-rollout-shrinker shrink

    codex-rollout-shrinker is a local CLI and transformation core for safely
    shrinking and restoring a manually forked and compacted Codex rollout while it
    is cold.

    The operator performs the model-dependent work in an ordinary Codex frontend:

    1. Fork the source thread.
    2. Switch to the fork and run context compaction.
    3. Wait for compaction to finish.
    4. Close the frontend so its Codex backend releases the rollout writer.
    5. Run inspect, then run the explicitly confirmed shrink command with the
      fork thread ID.
    6. Reopen the same fork thread in Codex.

    Environment

    • Windows host: Windows 11, build 10.0.22000.2538;
    • execution environment: WSL2, Ubuntu 24.04.4 LTS, Linux
      6.18.33.2-microsoft-standard-WSL2, x86-64, ext4;
    • frontend: VS Code 1.134.0 x64 with OpenAI ChatGPT/Codex extension
      26.825.41651 running in WSL;
    • frontend-embedded Codex: codex-cli 0.151.0-alpha.7.1;

    Rollout and context measurements

    The source was a legacy fork with absent/null history_base, 10 compaction
    windows, and a usable window-10 checkpoint. “Turn” is ambiguous in legacy
    rollouts, so the raw counters are stated explicitly: the source had 28
    task_started events, 28 matching task_complete events, 28 user
    response_item messages, and 18 turn_context records.

    Measurement Value
    Source records 5,982
    Source bytes 14,869,588
    Latest safe replay boundary record 5,898
    Latest checkpoint record 5,979, window 10
    Newest replacement_history 19 items
    Compact JSON size of that replacement array 25,000 bytes
    Retained records 86
    Published bytes 228,680
    Removed records 5,896 (98.5624%)
    Removed bytes 14,640,908 (98.4621%)
    Source-to-published size ratio 65.02x

    If this evidence confirms the use case, would the maintainers prefer an experimental thread/fork strategy or a dedicated app-server operation, and would they invite a backend-only contribution?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    appIssues related to the Codex desktop appapp-serverIssues involving app server protocol or interfacescontextIssues related to context management (including compaction)enhancementNew feature or requestperformance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions