Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ Versioning and Keep a Changelog conventions.

### Changed

- Add opt-in bounded native process reuse for Claude streaming and OpenCode serve,
alongside Codex app-server reuse. Retained processes use provider-specific
preflight checks, strict session/configuration isolation, idle expiry, and
no-replay delivery boundaries. Default one-process-per-turn behavior is unchanged.

- Preserve Windows system and profile environment variables when launching providers,
without inheriting unrelated credentials. Hide provider and cleanup console windows.
Add native process fixtures for arguments, stdin, failures, cancellation, and
Expand All @@ -30,6 +35,9 @@ Versioning and Keep a Changelog conventions.

### Added

- Add opt-in, bounded Codex app-server process reuse for in-process retained
runtimes, with strict runtime/configuration isolation, cold fallback at pool
capacity, idle expiry, and process-tree cleanup on cancellation or failure.
- Contain observer panic-payload cleanup failures and disable failed observers
across runtime clones. Report event-delivery wait separately from observed
first-text latency, preserving bounded backpressure.
Expand Down
51 changes: 25 additions & 26 deletions docs/adr/0004-prepared-provider-processes.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR 0004: Prepared provider processes

- Status: Proposed
- Status: Accepted (Claude, Codex, and OpenCode retained turns implemented; explicit prewarming deferred)
- Date: 2026-09-23

## Problem
Expand All @@ -16,42 +16,39 @@ first assistant text. A first output frame is not evidence of provider readiness
or model request submission. Provider-specific readiness requires an explicit
handshake acknowledgement, not a sleep or an empty model turn.

## Proposed ownership
## Ownership

The SDK owns a bounded process supervisor and provider protocol state. Fleet owns
when a user has selected enough configuration to prepare, durable conversation
records, authorization, feature rollout, and presentation. Listing projects or
conversations must never spawn provider processes.

Existing `AgentRuntime::run` and retained-client constructors preserve lazy,
one-process-per-turn behavior. A separate opt-in client configuration enables
retained processes. Unsupported adapters and remote protocol versions report a
typed capability error rather than silently claiming preparation succeeded.
Existing `AgentRuntime::run` and default retained-client constructors preserve
lazy, one-process-per-turn behavior. `provider_process_retention` opts the
in-process retained client into bounded reuse for the built-in Claude streaming,
Codex app-server, and OpenCode serve protocols. `codex_process_retention` remains
the compatibility opt-in for Codex alone. Custom adapters remain disabled unless
they implement the lifecycle contract.

## Proposed lifecycle
## Lifecycle

1. Acquire a logical runtime with project, provider, sandbox and launch settings.
2. Explicitly prepare it, without a prompt or invocation identifier. This starts
the process and completes the provider handshake; it does not call a model,
create a synthetic transcript message, or grant tool execution.
3. Send a turn. Lazy sending performs preparation automatically. Sending during
preparation joins the same bounded operation and submits exactly once.
4. On a successful turn, keep the provider connection and continue draining its
2. Send the first real turn. This lazily starts and initializes the app server,
then submits the prompt exactly once.
3. On a successful turn, keep the provider connection and continue draining its
bounded event stream. Idle tool/background events belong to the runtime and
must not be attached to the next invocation.
5. Dispose or expire the idle process, confirming process-tree teardown. Preserve
4. Dispose or expire the idle process, confirming process-tree teardown. Preserve
session identity so later work can explicitly resume from provider persistence.

The process lifecycle distinguishes unprepared, queued, preparing, ready, busy,
failed, stopping and stopped. A logical runtime being acquired is not provider
readiness. Preparation errors expose delivery=not_sent. Failure after submission
preserves the existing accepted/possibly_sent semantics and must not replay a
prompt automatically.
Acquiring a logical runtime does not start a provider process or claim provider
readiness. Failure before submission remains `delivery=not_sent`; failure after
submission preserves accepted/possibly-sent semantics and never replays a prompt.

Preparation has a configurable deadline, bounded concurrency, global process
capacity and idle expiration. Active turns cannot be evicted to admit speculative
preparation. Abandoned preparations release their capacity. Disposing while
preparing prevents late successful readiness from resurrecting the runtime.
Retention has bounded turn concurrency, separate global process capacity and idle
expiration. Capacity exhaustion falls back to the ordinary cold-turn path.
Cancellation, timeout, dropped futures, disposal and ambiguous idle output poison
the connection and terminate its process tree before it can be reused.

## Configuration and credentials

Expand All @@ -77,9 +74,11 @@ a prerequisite to Fleet enabling retention.

Claude keeps a streaming input channel open and separates initialization from user
messages. Codex keeps one app-server connection and thread alive, issues one
initialize handshake per process, and starts later turns on that connection. Both
need bounded background draining, crash detection, cancellation, late-event
isolation and cleanup. One-shot Codex exec and unsupported providers remain lazy.
initialize handshake per process, and starts later turns on that connection.
OpenCode retains its loopback serve process while creating a fresh health-checked
HTTP/SSE bridge for each turn. All three use bounded initialization and inactivity
deadlines, crash detection, cancellation, late-event isolation, and process-tree
cleanup. One-shot modes and unsupported providers remain lazy.

## Fleet adoption

Expand Down
46 changes: 46 additions & 0 deletions docs/reference/retained-runtimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,52 @@ reports `session_resume: true` and `retained_process: false`: Claude, Codex, and
OpenCode can continue their provider-native sessions even though the CLI is
currently relaunched for each turn.

Provider process reuse is explicit and in-process only. `ProviderProcessRetention`
enables the built-in Claude streaming driver, Codex app-server driver, and
OpenCode serve driver with one shared process budget:

```rust
use std::time::Duration;
use temps_agent_runtime::providers::{Claude, Codex, OpenCode};
use temps_agent_runtime::{AgentRuntime, ProviderProcessRetention};

let mut builder = AgentRuntime::builder()
.provider_process_retention(ProviderProcessRetention {
max_processes: 4,
idle_timeout: Duration::from_secs(120),
initialization_timeout: Duration::from_secs(30),
active_inactivity_timeout: Some(Duration::from_secs(30 * 60)),
});
builder.register(Claude::default());
builder.register(Codex::app_server());
builder.register(OpenCode::serve());
let runtime = builder.build()?;
```

`codex_process_retention` remains available for applications that only want
Codex app-server reuse. Both settings are disabled by default. One-shot modes,
custom adapters, and remote OpenCode transports retain their existing behavior.

Pass that runtime to `InProcessRuntimeClient::new`. Each `RuntimeId` owns at most
one process. The SDK compares the complete sandbox-wrapped command plus working
directory, model, reasoning, permission, harness, launch context, compaction and
sandbox requirements before reuse. Changed explicit credentials or environment
replace the process. Ambient inherited environment is read when a process starts;
applications that rotate ambient credentials must dispose the logical runtime or
rebuild the `AgentRuntime`.

The process pool is bounded independently from acquired logical runtimes. A new
runtime that reaches capacity runs through the ordinary one-process turn path;
existing retained runtimes remain warm and usable without waiting for an idle
slot.
Crash, failed health checks, cancellation, timeout, dropped turn futures, and
disposal retire the process tree. Before submitting a later prompt, Claude uses
a bounded read-only control probe and OpenCode checks its loopback health endpoint
through a fresh per-turn bridge. A pre-submission failure may start a replacement;
the SDK never automatically replays a prompt after delivery is possible. Codex
correlates native turn IDs, OpenCode requires the active session ID, and Claude
retires a connection that emits ambiguous idle output.

Use `RuntimeHandle::configuration_impact` before presenting a live setting
change. The compatibility driver applies per-turn model, reasoning, permission,
harness, launch-context, environment, and timeout changes live. Provider,
Expand Down
42 changes: 41 additions & 1 deletion src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use crate::{
///
/// Programs and arguments remain separate values throughout execution; this
/// crate never constructs a shell command string.
#[derive(Clone)]
#[derive(Clone, PartialEq, Eq)]
pub struct CommandSpec {
/// Executable path.
pub program: PathBuf,
Expand Down Expand Up @@ -213,6 +213,8 @@ pub struct AdapterOutput {
pub writes: Vec<Vec<u8>>,
/// True after a provider terminal frame. The runtime then closes stdin.
pub terminal: bool,
/// The provider acknowledged the submitted prompt for this turn.
pub turn_submitted: bool,
}

/// Protocol carrier an adapter supplies in place of the provider's own stdio.
Expand Down Expand Up @@ -254,6 +256,14 @@ pub trait AgentAdapter: Send + Sync {
/// Provider implemented by this adapter.
fn provider(&self) -> Provider;

/// Whether this exact adapter supports retaining one native process across turns.
///
/// Custom adapters remain disabled unless they explicitly implement the
/// complete lifecycle contract.
fn supports_retained_process(&self) -> bool {
false
}

/// Executable name or path meaningful inside the selected execution transport.
fn executable(&self) -> PathBuf {
PathBuf::from(match self.provider() {
Expand Down Expand Up @@ -384,6 +394,36 @@ pub trait AgentAdapter: Send + Sync {
Ok(())
}

/// Seed a retained turn, optionally reusing provider-specific process state.
fn prepare_retained_turn(
&self,
request: &TurnRequest,
state: &mut AdapterState,
process_hint: Option<u64>,
) -> Result<()> {
let _ = process_hint;
self.prepare_turn(request, state)
}

/// Opaque provider-specific state needed to address this retained process.
fn retained_process_hint(&self, state: &AdapterState) -> Option<u64> {
let _ = state;
None
}

/// Begin another turn on an already initialized retained process.
///
/// Returning `None` means the adapter cannot safely reuse its process.
fn retained_turn_start(&self, state: &AdapterState) -> Result<Option<Vec<u8>>> {
let _ = state;
Ok(None)
}

/// Mark parser state as belonging to a retained native process.
fn mark_retained_turn(&self, state: &mut AdapterState) {
let _ = state;
}

/// Supply a protocol carrier to use instead of the child's stdout and stdin.
///
/// Called once, after the provider process is spawned and before the first
Expand Down
11 changes: 7 additions & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ pub use extensions::{
HarnessMcpServer, HarnessSkill, McpServerManagementRequest, SkillManagementRequest,
};
pub use interactions::{InteractionBroker, InteractionBrokerError, InteractionResolution};
pub use runtime::{AgentRuntime, AgentRuntimeBuilder};
pub use runtime::{
AgentRuntime, AgentRuntimeBuilder, CodexProcessRetention, ProviderProcessRetention,
};
pub use sandbox::{
ResolvedSandboxProfile, SandboxBackend, SandboxCapabilities, SandboxContext, SandboxError,
SandboxPathAccess, SandboxProfileChange, SandboxProfileManager, SandboxProfileRef,
Expand Down Expand Up @@ -101,9 +103,10 @@ pub use types::{
AgentTaskActivityKind, AgentTaskUsage, ApprovalDecision, ApprovalRequest, AutoCompactionPolicy,
CompactionTrigger, ContextCompaction, ContextWindowUsage, DenyAll, EventSink,
InteractionHandler, LaunchContext, LaunchContextCapabilities, McpServerConfig, NoopEventSink,
PermissionMode, PermissionSupport, Provider, ProviderReadiness, QuestionAnswer, QuestionOption,
QuestionPrompt, QuestionRequest, RunStatus, SecretString, ToolCallStatus, ToolProcessPolicy,
TurnCapabilities, TurnEvent, TurnProvenance, TurnRequest, TurnResult, Usage,
PermissionMode, PermissionSupport, Provider, ProviderProcessStatus, ProviderReadiness,
QuestionAnswer, QuestionOption, QuestionPrompt, QuestionRequest, RunStatus, SecretString,
ToolCallStatus, ToolProcessPolicy, TurnCapabilities, TurnEvent, TurnProvenance, TurnRequest,
TurnResult, Usage,
};

pub use startup::{StartupObserver, StartupStage, StartupTiming};
4 changes: 4 additions & 0 deletions src/providers/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,10 @@ impl AgentAdapter for Claude {
Provider::Claude
}

fn supports_retained_process(&self) -> bool {
true
}

fn executable(&self) -> PathBuf {
self.configured_executable()
}
Expand Down
18 changes: 18 additions & 0 deletions src/providers/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,10 @@ impl AgentAdapter for Codex {
Provider::Codex
}

fn supports_retained_process(&self) -> bool {
self.app_server_mode()
}

fn executable(&self) -> PathBuf {
self.configured_executable()
}
Expand Down Expand Up @@ -1016,6 +1020,20 @@ impl AgentAdapter for Codex {
Ok(())
}

fn retained_turn_start(&self, state: &AdapterState) -> Result<Option<Vec<u8>>> {
if self.app_server_mode() {
codex_app_server::retained_turn_start(state).map(Some)
} else {
Ok(None)
}
}

fn mark_retained_turn(&self, state: &mut AdapterState) {
if self.app_server_mode() {
codex_app_server::mark_retained(state);
}
}

fn interrupt_request(&self, state: &AdapterState) -> Option<Vec<u8>> {
self.app_server_mode()
.then(|| codex_app_server::interrupt(state))
Expand Down
Loading
Loading