Files
codex/codex-rs/exec-server/tests/environment.rs
jif 13ba8058f2 Resolve selected capability roots without starting executors (#31581)
## Why

A thread can select skill roots that live in an executor environment.
`skills/list` needs a passive snapshot of the roots that are usable now:
it must not start an executor, wait for recovery, or reconnect a failed
environment.

The initial implementation checked the immutable first startup result.
After a successful connection later entered recovery or failed, that
result still looked successful. A read-only catalog request could then
wait for recovery or trigger a new connection while reading the
filesystem.

## What

- inspect readiness from the current exec-server connection state
- return roots only while their environment can serve a request
immediately
- omit environments that have not started, are connecting, or are
recovering
- return warnings for missing environments and terminal connection
failures
- add a fail-fast filesystem view that never starts, waits for, or
reconnects an environment
- expose the passive selected-root snapshot through `CodexThread`

## Behavior

- Local and currently connected environments are ready.
- Starting and recovering environments are omitted without a warning so
callers can retry later.
- Missing and terminally failed environments are omitted with a warning.
- A disconnect between readiness inspection and filesystem access fails
promptly instead of crossing into the normal recovery path.
- Normal model-turn and execution paths keep their existing reconnect
behavior.

## Design

The recovery policy is private to the exec-server client. Callers choose
the explicit fail-fast filesystem method; the existing client and
filesystem APIs remain reconnecting. This keeps the passive contract at
the transport boundary instead of plumbing timeout or retry flags
through the skills stack.

## Coverage

- a lazy stdio environment stays unstarted during passive inspection
- missing and terminally failed environments surface warnings
- a real websocket disconnect proves current readiness drops, a
previously acquired fail-fast filesystem handle returns promptly, and
readiness returns after recovery

## Scope

This PR only provides passive readiness and fail-fast filesystem
primitives. It does not add app-server API fields or notifications.

## Stack

- #31582 uses these primitives for experimental thread-scoped
`skills/list`.
- #30228 adds targeted invalidation notifications.
2026-07-09 11:17:05 +01:00

85 lines
2.7 KiB
Rust

mod common;
use std::time::Duration;
use anyhow::Context;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::REMOTE_ENVIRONMENT_ID;
use codex_exec_server::SelectedCapabilityRootsStatus;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_utils_path_uri::PathUri;
use common::exec_server::exec_server;
use pretty_assertions::assert_eq;
use tokio::time::sleep;
use tokio::time::timeout;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial(remote_exec_server)]
async fn selected_capability_inspection_tracks_connection_recovery() -> anyhow::Result<()> {
let server = exec_server().await?;
let mut proxy = server.disconnectable_websocket_proxy().await?;
let manager = EnvironmentManager::create_for_tests(
Some(proxy.websocket_url().to_string()),
/*local_runtime_paths*/ None,
)
.await;
let environment = manager
.default_environment()
.context("remote environment")?;
environment.info().await?;
let skill_root_path = PathUri::parse("file:///plugins/demo")?;
let selected_root = SelectedCapabilityRoot {
id: "demo@1".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
path: skill_root_path.clone(),
},
};
assert_eq!(
manager.inspect_selected_capability_roots(std::slice::from_ref(&selected_root)),
SelectedCapabilityRootsStatus {
ready_roots: vec![selected_root.clone()],
warnings: Vec::new(),
}
);
let file_system = environment.get_filesystem_without_reconnect();
proxy.pause_and_disconnect().await?;
assert_eq!(
manager.inspect_selected_capability_roots(std::slice::from_ref(&selected_root)),
SelectedCapabilityRootsStatus::default()
);
let read_result = timeout(
Duration::from_secs(1),
file_system.read_directory(&skill_root_path, /*sandbox*/ None),
)
.await
.context("passive filesystem read waited for recovery")?;
assert!(read_result.is_err());
proxy.resume()?;
let recovered_status = timeout(Duration::from_secs(5), async {
loop {
let status =
manager.inspect_selected_capability_roots(std::slice::from_ref(&selected_root));
if !status.ready_roots.is_empty() {
break status;
}
sleep(Duration::from_millis(10)).await;
}
})
.await
.context("environment did not recover")?;
assert_eq!(
recovered_status,
SelectedCapabilityRootsStatus {
ready_roots: vec![selected_root],
warnings: Vec::new(),
}
);
Ok(())
}