mirror of
https://github.com/openai/codex.git
synced 2026-09-11 20:36:49 +00:00
Propagate Apps tool refreshes to existing threads (#43900)
## Why Refreshing installed Apps without a thread should update the tools available to existing threads on their next turn. ## What changed - Publish live tool catalogs to clients with matching transport, auth, protocol, and listing settings within the same account and home directory. Ignore the thread attribution header when matching scopes. - Adopt updated tools before catalog reads and new calls, preserving running calls and rejecting calls prepared against an outdated catalog revision. - Keep the newest successful fetch per scope so older refreshes cannot overwrite newer tools, and exclude disk snapshots from live updates. ## Testing Add regression coverage for refresh propagation to an existing thread without another tools listing, scope and account isolation, out-of-order refreshes, late client startup, and running versus stale prepared calls. GitOrigin-RevId: 7a5ee34e23742ce374c6647dc8928b76ea622448
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
//! workspace. Disk is best-effort cold-start persistence; a context reads it
|
||||
//! once when created and never rereads it. Full connector metadata is
|
||||
//! owned by the connector metadata store, not by this module.
|
||||
//! Live catalog subscriptions publish only successful fetches from a matching
|
||||
//! discovery scope, never disk snapshots or another scope's discovery winner.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@@ -22,6 +24,7 @@ use codex_protocol::mcp::McpServerInfo;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use self::persistence::load_cached_codex_apps_server_info;
|
||||
use self::persistence::load_cached_connector_runtime_for_identity;
|
||||
@@ -31,6 +34,8 @@ use self::persistence::tools_cache_path;
|
||||
|
||||
const MCP_TOOLS_CACHE_PUBLISH_DURATION_METRIC: &str = "codex.mcp.tools.cache_publish.duration_ms";
|
||||
|
||||
type LiveCatalog<T> = watch::Sender<Option<Arc<ConnectorRuntimeSnapshot<T>>>>;
|
||||
|
||||
/// Values stored in the connector runtime's persisted tool snapshot.
|
||||
///
|
||||
/// The runtime uses the connector-owned Codex Apps cache layout for every
|
||||
@@ -93,6 +98,7 @@ pub fn connector_runtime_cache_path(codex_home: &Path, auth: Option<&CodexAuth>)
|
||||
pub struct ConnectorRuntimeSnapshot<T> {
|
||||
tools: Vec<T>,
|
||||
refreshed_at: SystemTime,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl<T> ConnectorRuntimeSnapshot<T> {
|
||||
@@ -166,24 +172,47 @@ impl<T: ConnectorRuntimePayload> ConnectorRuntimeManager<T> {
|
||||
.entry(identity.clone())
|
||||
.or_insert_with(|| Arc::new(ConnectorRuntimeEntry::new(identity, self.disk_cache)))
|
||||
.clone();
|
||||
ConnectorRuntimeContext { entry }
|
||||
ConnectorRuntimeContext {
|
||||
entry,
|
||||
live_catalog: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle to one shared account/workspace connector runtime.
|
||||
pub struct ConnectorRuntimeContext<T: ConnectorRuntimePayload> {
|
||||
entry: Arc<ConnectorRuntimeEntry<T>>,
|
||||
live_catalog: Option<LiveCatalog<T>>,
|
||||
}
|
||||
|
||||
impl<T: ConnectorRuntimePayload> Clone for ConnectorRuntimeContext<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
entry: Arc::clone(&self.entry),
|
||||
live_catalog: self.live_catalog.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ConnectorRuntimePayload> ConnectorRuntimeContext<T> {
|
||||
/// Groups executable catalogs by equivalent discovery inputs within this account/home.
|
||||
/// The caller must include the endpoint, auth configuration and listing protocol in `scope`.
|
||||
/// Account-wide discovery reads are unaffected.
|
||||
pub fn with_live_scope(mut self, scope: String) -> Self {
|
||||
self.live_catalog = Some(
|
||||
lock_unpoisoned(&self.entry.live_catalogs)
|
||||
.entry(scope)
|
||||
.or_insert_with(|| watch::channel(None).0)
|
||||
.clone(),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
/// Subscribes to accepted live tools without refetching or waiting for other clients.
|
||||
pub fn subscribe(&self) -> Option<watch::Receiver<Option<Arc<ConnectorRuntimeSnapshot<T>>>>> {
|
||||
self.live_catalog.as_ref().map(watch::Sender::subscribe)
|
||||
}
|
||||
|
||||
pub fn current_snapshot(&self) -> Option<Arc<ConnectorRuntimeSnapshot<T>>> {
|
||||
self.entry.current_snapshot.load_full()
|
||||
}
|
||||
@@ -254,6 +283,23 @@ impl<T: ConnectorRuntimePayload> ConnectorRuntimeContext<T> {
|
||||
) -> Arc<ConnectorRuntimeSnapshot<T>> {
|
||||
let publish_start = Instant::now();
|
||||
let mut last_accepted_generation = lock_unpoisoned(&self.entry.last_accepted_generation);
|
||||
let snapshot = Arc::new(ConnectorRuntimeSnapshot {
|
||||
tools,
|
||||
refreshed_at: SystemTime::now(),
|
||||
generation: ticket.generation,
|
||||
});
|
||||
if let Some(live_catalog) = &self.live_catalog {
|
||||
live_catalog.send_if_modified(|current| {
|
||||
if current
|
||||
.as_ref()
|
||||
.is_some_and(|current| current.generation >= ticket.generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
*current = Some(Arc::clone(&snapshot));
|
||||
true
|
||||
});
|
||||
}
|
||||
if ticket.generation <= *last_accepted_generation
|
||||
&& let Some(snapshot) = self.current_snapshot()
|
||||
{
|
||||
@@ -266,11 +312,6 @@ impl<T: ConnectorRuntimePayload> ConnectorRuntimeContext<T> {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
let snapshot = Arc::new(ConnectorRuntimeSnapshot {
|
||||
tools,
|
||||
refreshed_at: SystemTime::now(),
|
||||
});
|
||||
|
||||
*last_accepted_generation = ticket.generation;
|
||||
self.entry
|
||||
.current_snapshot
|
||||
@@ -326,6 +367,7 @@ struct ConnectorRuntimeEntry<T: ConnectorRuntimePayload> {
|
||||
current_snapshot: ArcSwapOption<ConnectorRuntimeSnapshot<T>>,
|
||||
next_fetch_generation: AtomicU64,
|
||||
last_accepted_generation: Mutex<u64>,
|
||||
live_catalogs: Mutex<HashMap<String, LiveCatalog<T>>>,
|
||||
}
|
||||
|
||||
impl<T: ConnectorRuntimePayload> ConnectorRuntimeEntry<T> {
|
||||
@@ -342,6 +384,7 @@ impl<T: ConnectorRuntimePayload> ConnectorRuntimeEntry<T> {
|
||||
current_snapshot: ArcSwapOption::from(current_snapshot),
|
||||
next_fetch_generation: AtomicU64::new(0),
|
||||
last_accepted_generation: Mutex::new(0),
|
||||
live_catalogs: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ pub(crate) fn load_cached_connector_runtime_for_identity<T: ConnectorRuntimePayl
|
||||
ConnectorRuntimeSnapshot {
|
||||
tools: cache.tools,
|
||||
refreshed_at: modified_at,
|
||||
generation: 0,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -233,6 +234,7 @@ pub(crate) fn write_cached_codex_apps_tools_for_test<T>(
|
||||
let snapshot = ConnectorRuntimeSnapshot {
|
||||
tools: tools.to_vec(),
|
||||
refreshed_at: SystemTime::now(),
|
||||
generation: 0,
|
||||
};
|
||||
cache_context
|
||||
.entry
|
||||
@@ -263,6 +265,7 @@ where
|
||||
let snapshot = ConnectorRuntimeSnapshot {
|
||||
tools: tools.to_vec(),
|
||||
refreshed_at: SystemTime::now(),
|
||||
generation: 0,
|
||||
};
|
||||
write_cached_connector_runtime(cache_context, &snapshot)
|
||||
}
|
||||
|
||||
@@ -492,6 +492,47 @@ fn codex_apps_tools_cache_scopes_non_utf8_home_disk_paths() {
|
||||
assert_ne!(cache_paths[0], cache_paths[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_catalogs_isolate_scopes_and_reject_older_refreshes() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let manager = ConnectorRuntimeManager::<TestTool>::new_without_cache();
|
||||
let context = manager.context(
|
||||
codex_home.path().to_path_buf(),
|
||||
ConnectorRuntimeContextKey::personal(
|
||||
Some("account".to_string()),
|
||||
/*chatgpt_user_id*/ None,
|
||||
),
|
||||
);
|
||||
let scope_a = context.clone().with_live_scope("endpoint-a".to_string());
|
||||
let scope_b = context.with_live_scope("endpoint-b".to_string());
|
||||
let updates_a = scope_a.subscribe().expect("scope A subscription");
|
||||
let updates_b = scope_b.subscribe().expect("scope B subscription");
|
||||
let server_info = create_test_server_info("Codex Apps");
|
||||
let older_a = scope_a.begin_fetch(ConnectorRuntimeFetchSource::Startup);
|
||||
let newer_a = scope_a.begin_fetch(ConnectorRuntimeFetchSource::HardRefresh);
|
||||
let newest_b = scope_b.begin_fetch(ConnectorRuntimeFetchSource::HardRefresh);
|
||||
let tools_a = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "newer-a")];
|
||||
let tools_b = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "newest-b")];
|
||||
|
||||
scope_b.publish_if_newest_accepted(newest_b, &server_info, tools_b.clone());
|
||||
assert!(updates_a.borrow().is_none());
|
||||
let discovery = scope_a.publish_if_newest_accepted(newer_a, &server_info, tools_a.clone());
|
||||
assert_eq!(discovery, tools_b);
|
||||
scope_a.publish_if_newest_accepted(
|
||||
older_a,
|
||||
&server_info,
|
||||
vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "stale-a")],
|
||||
);
|
||||
assert_eq!(
|
||||
updates_a.borrow().as_ref().expect("scope A tools").tools(),
|
||||
&tools_a
|
||||
);
|
||||
assert_eq!(
|
||||
updates_b.borrow().as_ref().expect("scope B tools").tools(),
|
||||
&tools_b
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contexts_for_different_identities_keep_isolated_snapshots() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
@@ -504,6 +545,8 @@ fn contexts_for_different_identities_keep_isolated_snapshots() {
|
||||
is_workspace_account: false,
|
||||
},
|
||||
);
|
||||
let context_a = context_a.with_live_scope("same-endpoint".to_string());
|
||||
let updates_a = context_a.subscribe().expect("account A subscription");
|
||||
let tools_a = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "tool-a")];
|
||||
let snapshot_a = context_a.publish_runtime_if_newest_accepted(
|
||||
context_a.begin_fetch(ConnectorRuntimeFetchSource::HardRefresh),
|
||||
@@ -519,6 +562,8 @@ fn contexts_for_different_identities_keep_isolated_snapshots() {
|
||||
is_workspace_account: false,
|
||||
},
|
||||
);
|
||||
let context_b = context_b.with_live_scope("same-endpoint".to_string());
|
||||
let updates_b = context_b.subscribe().expect("account B subscription");
|
||||
let same_context_a = manager.context(
|
||||
codex_home.path().to_path_buf(),
|
||||
ConnectorRuntimeContextKey {
|
||||
@@ -527,6 +572,7 @@ fn contexts_for_different_identities_keep_isolated_snapshots() {
|
||||
is_workspace_account: false,
|
||||
},
|
||||
);
|
||||
let same_context_a = same_context_a.with_live_scope("same-endpoint".to_string());
|
||||
|
||||
assert!(Arc::ptr_eq(
|
||||
&snapshot_a,
|
||||
@@ -566,6 +612,22 @@ fn contexts_for_different_identities_keep_isolated_snapshots() {
|
||||
&snapshot_b,
|
||||
&context_b.current_snapshot().expect("context B snapshot")
|
||||
));
|
||||
assert_eq!(
|
||||
updates_a
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.expect("account A tools")
|
||||
.tools(),
|
||||
&newer_tools_a
|
||||
);
|
||||
assert_eq!(
|
||||
updates_b
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.expect("account B tools")
|
||||
.tools(),
|
||||
&tools_b
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user