mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
## Why Authorization updates during a pending Guardian approval can invalidate its decision. Retries need the updated guidance without inheriting the invalidated review or losing earlier valid context. ## What changed - Validate authorization while the reviewer is reserved, before publishing its history checkpoint. - Roll back stale attempts to the last committed review, preserving earlier valid reviews across repeated invalidations. - Pin the issuing action's repository instruction snapshot for reviewers and retries, avoiding fresh instruction discovery through disconnected executors. - Treat stale authorization as a request for a new review rather than a failed decision. ## Testing Add regression coverage for repeated approval invalidation, retention of earlier valid context, exclusion of stale decisions, and execution of the pending action exactly once. Extend the disconnected-executor test to verify that retries retain repository instructions and use updated authorization. GitOrigin-RevId: 0bff6cc551fee1f0905082eb97ccdecaa8628867
205 lines
8.4 KiB
Rust
205 lines
8.4 KiB
Rust
//! Resolves global, thread, and repository instructions into one validated snapshot.
|
|
//! Refreshes are serialized; the state lock is never held while calling providers.
|
|
//! Isolated reviewers can pin a captured repository snapshot without executor discovery.
|
|
|
|
use crate::agents_md::LoadedAgentsMd;
|
|
use crate::agents_md::load_project_instructions;
|
|
use crate::config::Config;
|
|
use crate::environment_selection::TurnEnvironmentSnapshot;
|
|
use codex_extension_api::Instructions;
|
|
use codex_extension_api::ThreadInstructionsProvider;
|
|
use codex_extension_api::UserInstructionsProvider;
|
|
use codex_protocol::config_types::TrustLevel;
|
|
use codex_protocol::error::CodexErr;
|
|
use codex_protocol::error::Result as CodexResult;
|
|
use codex_protocol::protocol::TurnEnvironmentSelection;
|
|
use codex_utils_string::approx_bytes_for_tokens;
|
|
use codex_utils_string::approx_tokens_from_byte_count;
|
|
use std::sync::Arc;
|
|
use tokio::sync::Mutex;
|
|
use tokio::sync::Semaphore;
|
|
|
|
/// Owns instruction sources, refresh serialization, and the applied snapshot.
|
|
pub(crate) struct AgentsMdManager {
|
|
refresh_lock: Semaphore,
|
|
state: Mutex<AgentsMdState>,
|
|
}
|
|
|
|
/// Subagents inherit applied snapshots, plus thread providers that opt into sharing.
|
|
#[derive(Clone, Default)]
|
|
pub(crate) struct SessionInstructions {
|
|
pub(crate) user: Option<Instructions>,
|
|
pub(crate) thread: Option<Instructions>,
|
|
/// A reviewer-owned repository snapshot. `Some`, even when empty, skips discovery.
|
|
pub(crate) project_snapshot: Option<Arc<LoadedAgentsMd>>,
|
|
pub(crate) user_provider: Option<Arc<dyn UserInstructionsProvider>>,
|
|
pub(crate) thread_provider: Option<Arc<dyn ThreadInstructionsProvider>>,
|
|
}
|
|
|
|
struct AgentsMdState {
|
|
instructions: SessionInstructions,
|
|
cache: AgentsMdCache,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct AgentsMdCache {
|
|
selections: Option<Vec<TurnEnvironmentSelection>>,
|
|
active_project_trust_level: Option<TrustLevel>,
|
|
loaded: Option<Arc<LoadedAgentsMd>>,
|
|
}
|
|
|
|
impl AgentsMdManager {
|
|
pub(crate) fn new(mut instructions: SessionInstructions) -> Self {
|
|
instructions.user = normalize_instructions(instructions.user);
|
|
instructions.thread = normalize_instructions(instructions.thread);
|
|
Self {
|
|
refresh_lock: Semaphore::new(/*permits*/ 1),
|
|
state: Mutex::new(AgentsMdState {
|
|
instructions,
|
|
cache: AgentsMdCache::default(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Resolves and validates one coherent snapshot for startup or a request boundary.
|
|
/// Providers own fetching and caching. Warnings survive validation failures;
|
|
/// only validated snapshots replace the applied host instructions.
|
|
#[tracing::instrument(name = "agents_md.refresh", skip_all)]
|
|
pub(crate) async fn refresh(
|
|
&self,
|
|
config: &Config,
|
|
environments: &TurnEnvironmentSnapshot,
|
|
) -> (CodexResult<Option<Arc<LoadedAgentsMd>>>, Vec<String>) {
|
|
// Serialize overlapping captures without blocking reads of the applied snapshot.
|
|
let Ok(_refresh_guard) = self.refresh_lock.acquire().await else {
|
|
return (
|
|
Err(CodexErr::Fatal(
|
|
"instruction refresh semaphore closed".to_string(),
|
|
)),
|
|
Vec::new(),
|
|
);
|
|
};
|
|
let selections = environments
|
|
.turn_environments()
|
|
.map(|environment| environment.selection.clone())
|
|
.collect::<Vec<_>>();
|
|
let active_project_trust_level = config.active_project.trust_level;
|
|
let (mut instructions, cached, refresh_repository) = {
|
|
let mut state = self.state.lock().await;
|
|
let refresh_repository = state.cache.selections.as_ref() != Some(&selections)
|
|
|| state.cache.active_project_trust_level != active_project_trust_level;
|
|
if refresh_repository {
|
|
// Tightened read permissions must not leave inaccessible instructions visible,
|
|
// even if discovery fails or the caller cancels the refresh.
|
|
state.cache = AgentsMdCache::default();
|
|
}
|
|
(
|
|
state.instructions.clone(),
|
|
state.cache.loaded.clone(),
|
|
refresh_repository,
|
|
)
|
|
};
|
|
let mut warnings = Vec::new();
|
|
if let Some(provider) = &instructions.user_provider {
|
|
let loaded = provider.load_user_instructions().await;
|
|
instructions.user = normalize_instructions(loaded.instructions);
|
|
warnings.extend(loaded.warnings);
|
|
}
|
|
if let Some(provider) = &instructions.thread_provider {
|
|
let loaded = provider.load_thread_instructions().await;
|
|
instructions.thread = normalize_instructions(loaded.instructions);
|
|
warnings.extend(loaded.warnings);
|
|
}
|
|
|
|
let result = async {
|
|
if let Some(input) = &instructions.thread {
|
|
validate_thread_instruction_size(input.text.len())?;
|
|
}
|
|
if !refresh_repository {
|
|
let state = self.state.lock().await;
|
|
if state.instructions.user == instructions.user
|
|
&& state.instructions.thread == instructions.thread
|
|
{
|
|
return Ok(cached);
|
|
}
|
|
}
|
|
|
|
let loaded = if let Some(project) = &instructions.project_snapshot {
|
|
project.as_ref().clone()
|
|
} else if refresh_repository {
|
|
load_project_instructions(config, /*user_instructions*/ None, environments)
|
|
.await?
|
|
.unwrap_or_default()
|
|
} else {
|
|
cached.as_deref().cloned().unwrap_or_default()
|
|
}
|
|
.with_instructions(instructions.user.clone(), instructions.thread.clone())
|
|
.map(Arc::new);
|
|
let mut state = self.state.lock().await;
|
|
state.instructions = instructions;
|
|
state.cache = AgentsMdCache {
|
|
selections: Some(selections),
|
|
active_project_trust_level,
|
|
loaded: loaded.clone(),
|
|
};
|
|
Ok(loaded)
|
|
}
|
|
.await;
|
|
(result, warnings)
|
|
}
|
|
|
|
pub(crate) async fn get_loaded(&self) -> Option<Arc<LoadedAgentsMd>> {
|
|
self.state.lock().await.cache.loaded.clone()
|
|
}
|
|
|
|
/// Captures even an empty result, but never attributes another environment's cache
|
|
/// to a turn-only reviewer that has no issuing step snapshot.
|
|
pub(crate) async fn project_snapshot(
|
|
&self,
|
|
config: &Config,
|
|
environments: &TurnEnvironmentSnapshot,
|
|
) -> Option<Arc<LoadedAgentsMd>> {
|
|
let selections = environments
|
|
.turn_environments()
|
|
.map(|environment| environment.selection.clone())
|
|
.collect::<Vec<_>>();
|
|
let state = self.state.lock().await;
|
|
(state.cache.selections.as_ref() == Some(&selections)
|
|
&& state.cache.active_project_trust_level == config.active_project.trust_level)
|
|
.then(|| state.cache.loaded.clone().unwrap_or_default())
|
|
}
|
|
|
|
pub(crate) async fn inherited_instructions(&self) -> SessionInstructions {
|
|
let state = self.state.lock().await;
|
|
SessionInstructions {
|
|
user: state.instructions.user.clone(),
|
|
thread: state.instructions.thread.clone(),
|
|
thread_provider: state
|
|
.instructions
|
|
.thread_provider
|
|
.clone()
|
|
.filter(|provider| provider.share_with_subagents()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bound the new host-provided contribution independently of project_doc_max_bytes,
|
|
// which controls repository discovery. Existing global and combined instruction
|
|
// size policy is unchanged; reject oversized thread input rather than truncate it.
|
|
const MAX_THREAD_INSTRUCTIONS_TOKENS: usize = 10_000;
|
|
|
|
fn validate_thread_instruction_size(bytes: usize) -> CodexResult<()> {
|
|
if bytes > approx_bytes_for_tokens(MAX_THREAD_INSTRUCTIONS_TOKENS) {
|
|
let estimated_tokens = approx_tokens_from_byte_count(bytes);
|
|
return Err(CodexErr::InvalidRequest(format!(
|
|
"thread instructions exceed the limit of {MAX_THREAD_INSTRUCTIONS_TOKENS} estimated tokens ({estimated_tokens} estimated tokens provided)"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn normalize_instructions(instructions: Option<Instructions>) -> Option<Instructions> {
|
|
instructions.filter(|instructions| !instructions.text.trim().is_empty())
|
|
}
|