feat(codex): add opt-in retained app-server processes - #19
Conversation
|
| 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; |
There was a problem hiding this comment.
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.
| let result = entry.dispose().await; | ||
| self.runtimes.write().await.remove(runtime_id); | ||
| result?; |
There was a problem hiding this comment.
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.
| tokio::time::timeout( | ||
| Duration::from_millis(25), | ||
| read_bounded_retained_line(&mut io.reader, max_event_line_bytes), | ||
| ) | ||
| .await |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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.
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
CodexProcessRetentionand explicitly registerCodex::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
cargo check --no-default-featuresand all-target/all-feature Clippy with warnings denied passed.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.