Recover capability discovery after executor disconnects (#38420)

## Why

Transient executor disconnects could leave capability discovery and skill catalogs
stuck on a cached failure for the rest of a thread, even after the executor
reconnected.

## What changed

- Replay capability discovery after executor recovery and retry transient failures
  on later requests while continuing to cache permanent failures.
- Avoid caching skill catalogs produced from failed discovery so a later step can
  load the recovered catalog.
- Mark the MCP runtime dirty when recovered manifests change the projected MCP
  servers, and allow discovery to be cancelled with the turn.

## Testing

- Cover same-request recovery after a disconnect and recovery on a later request.
- Cover retry classification through connection-attempt errors and skill catalog
  caching after discovery recovers.

GitOrigin-RevId: a57f90844351e73ea831931f72a9ddc4e4f3335c
This commit is contained in:
hesham-oai authored and copyberry committed 2026-08-13 19:14:30 +00:00
1 parent 9946da9af1
commit 588e18aae5
10 files changed
+285 -31

No files matched your search

@@ -8,6 +8,8 @@ use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirementsToml;
use codex_exec_server::CapabilityRootDiscovery;
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
use codex_exec_server::LOCAL_FS;
use codex_extension_api::ConversationHistory;
use codex_extension_api::ExtensionData;
@@ -1125,6 +1127,45 @@ async fn selected_executor_catalog_follows_step_availability_and_reuses_its_cach
assert!(restored_fragment.body().contains("lint-fix"));
assert_eq!(1, list_calls.load(Ordering::Relaxed));
let failed_discovery = ExecutorCapabilityDiscoverySnapshot::new(
&selected_roots,
vec![Err("exec-server transport disconnected".to_string())],
Default::default(),
);
let recovered_discovery = ExecutorCapabilityDiscoverySnapshot::new(
&selected_roots,
vec![Ok(Arc::new(CapabilityRootDiscovery {
id: "lint-fix".to_string(),
path: PathUri::parse("file:///skills/lint-fix")?,
plugin: None,
skills: Vec::new(),
namespace_manifests: Vec::new(),
warnings: Vec::new(),
error: None,
}))],
Default::default(),
);
for (turn_id, discovery, expected_list_calls) in [
("failed-discovery", &failed_discovery, 2),
("recovered-discovery", &recovered_discovery, 3),
("cached-discovery", &recovered_discovery, 3),
] {
registry.context_contributors()[0]
.contribute_world_state(WorldStateContributionInput {
thread_id: codex_protocol::ThreadId::new(),
turn_id,
environments: &[],
ready_selected_capability_roots: &selected_roots,
executor_capability_discovery: Some(discovery),
extension_metrics: None,
session_store: &session_store,
thread_store: &thread_store,
turn_store: &ExtensionData::new(turn_id),
})
.await;
assert_eq!(expected_list_calls, list_calls.load(Ordering::Relaxed));
}
let mut listing_disabled_config = config.clone();
listing_disabled_config.include_instructions = false;
registry.config_contributors()[0].on_config_changed(