Skip to content

feat(codex): add opt-in retained app-server processes - #19

Merged
dviejokfs merged 4 commits into
mainfrom
feat/codex-process-retention
Sep 27, 2026
Merged

dviejokfs merged 4 commits into
mainfrom
feat/codex-process-retention

Conversation

@dviejokfs

Copy link
Copy Markdown
Contributor

Change

Opt-in Codex app-server retention lets consecutive turns in an acquired runtime reuse one native process. Default SDK behavior remains process-per-turn; custom executors keep their existing default hooks. Applications opt in with CodexProcessRetention and explicitly register Codex::app_server().

The pool is bounded, expires idle processes, and cold-runs new conversations when full. Configuration changes transfer the reserved slot to a replacement process. Cancellation, timeout, protocol failure, disposal, and abandoned turns retire the affected process. Turn correlation prevents stale events from reaching a later invocation; unsolicited idle output retires the connection.

Validation

  • Full suite: 322 passed, 1 ignored; focused Codex suite: 20 passed.
  • cargo check --no-default-features and all-target/all-feature Clippy with warnings denied passed.
  • Independent correctness review: all reported lifecycle, framing, capacity and cleanup findings resolved.
  • Fleet bridge: 32 tests passed, 2 existing opt-in tests ignored.
  • Live isolated Fleet Dev: an existing pre-upgrade conversation continued with the same provider session; successive turns reused native PID 54171. Permission changes replaced the process, idle expiry terminated it, and later turns resumed context correctly.
  • Restarting Fleet Dev with the flag absent continued the same conversation and restored process-per-turn exit behavior.

First-text samples were 7.374 seconds cold and 6.109 seconds warm. These are diagnostic samples, not a latency benchmark; PID reuse establishes retention. Claude and OpenCode process retention are outside this change.

@dviejokfs
dviejokfs marked this pull request as ready for review September 27, 2026 11:21
@dviejokfs
dviejokfs merged commit 1b0acf7 into main Sep 27, 2026
6 checks passed
@greptile-apps

greptile-apps Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 2/5

[High risk] Adds process pooling and reuse for Codex app-server runtime.

The PR is not yet safe to merge because retained-process lifecycle races can fail turns or let an old execution terminate a newly acquired runtime’s process.

Findings

  1. P1 Idle expiry can kill a new turn ▶
  2. P1 Failed disposal releases an active ID ▶
  3. P1 Idle read loses partial frames ▶
  4. P2 Retained turns lose notifications ▶

Summary

The PR adds opt-in, bounded Codex app-server reuse for in-process retained runtimes and excludes historical turns from resume and fork replies.

  • It adds process-capacity management, idle expiry, cleanup, and turn-correlation logic.
  • Review identified races in process retirement and runtime-ID reuse, plus an idle-frame parsing failure and lost uncorrelated notifications.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Acquired runtime] --> B[Start turn]
  B --> C{Retained slot available?}
  C -- No --> D[Cold process-per-turn run]
  C -- Yes --> E[Spawn or reuse app server]
  E --> F[Drive correlated turn]
  F -- Success --> G[Idle drain and expiry]
  F -- Failure --> H[Retire process]
  G -- Next turn --> E
  G -- Frame or expiry --> H
Loading

Reviews (1) · Last reviewed commit: "fix(codex): transfer retained process ca..."

Comment thread src/runtime.rs
Comment on lines +2873 to +2877
if retained_for_expiry.generation.load(Ordering::Acquire) == generation {
supervisor_for_expiry
.remove_if_same(&runtime_id, &retained_for_expiry)
.await;
let _ = retained_for_expiry.terminate().await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Idle expiry can kill a new turn

When idle expiry reads the old generation just before a new turn claims the process, it can still remove and terminate that process. If termination takes the I/O lock first, the new turn fails with “retained Codex process is no longer available” instead of running. The expiry decision needs to be coordinated with the new turn’s claim.

Comment thread src/retained.rs
Comment on lines +692 to +694
let result = entry.dispose().await;
self.runtimes.write().await.remove(runtime_id);
result?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed disposal releases an active ID

If disposal cannot confirm an active turn before its deadline, this removal lets another runtime acquire the same RuntimeId while the old execution may still be running. If that old execution later times out, it disposes the process by RuntimeId and can terminate the new runtime’s process. Keep the ID reserved until the old execution can no longer affect it.

Comment thread src/runtime.rs
Comment on lines +2852 to +2856
tokio::time::timeout(
Duration::from_millis(25),
read_bounded_retained_line(&mut io.reader, max_event_line_bytes),
)
.await

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Idle read loses partial frames

If an idle notification arrives in chunks more than 25 ms apart, the line reader can consume its prefix before this timeout cancels the read. The next turn may then read only the suffix as JSON-RPC and fail, rather than having the unsolicited frame retire the process. Preserve partial bytes across drain polls or retire the connection when a partial frame arrives.

Comment on lines +251 to +259
if turn.retained
&& !method.is_empty()
&& value.get("id").is_none()
&& reported_turn_id(&value).is_none()
{
// Uncorrelated notifications cannot be assigned to an invocation on a
// reused connection. Process-global state is refreshed explicitly by
// its dedicated APIs instead of leaking into the active turn stream.
return Ok(output);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Retained turns lose notifications

On a retained turn, this filter drops every notification without a turn ID. That includes thread/name/updated and warning, which the notification handler otherwise uses to update the session title or emit a warning. Subsequent turns can silently lose those updates; handle process-wide notifications separately from stale turn events.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant