mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
Consolidate Guardian reviewer lifecycle ownership (#45672)
## What changed - Use cancellation guards to tie reusable reviewers and temporary forks to their lifetimes, including when a review future is dropped. Replace cancelled reusable reviewers before reuse. - Share `ReviewerTasks` between the pool and `ThreadManager`, and wait for tracked cleanup during pool shutdown. Remove the separate session shutdown protocol. - Move denial cleanup from core task handling into Guardian's turn start, stop, and abort hooks. Expose `install_reviewer` to register both thread and turn lifecycle contributors together. ## Testing Add an integration test that exercises stale denial cleanup at turn start, completion, and interruption, including starting another turn after interruption. GitOrigin-RevId: 85badb235309c1e1576547d1746f7ee9fc66d545
This commit is contained in:
@@ -21,20 +21,3 @@ pub async fn run_before_review_deadline<T>(
|
||||
} => Err(GuardianReviewSessionOutcome::Aborted),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_before_review_deadline_with_cancel<T>(
|
||||
deadline: tokio::time::Instant,
|
||||
external_cancel: Option<&CancellationToken>,
|
||||
cancel_token: &CancellationToken,
|
||||
future: impl Future<Output = T>,
|
||||
) -> Result<T, GuardianReviewSessionOutcome> {
|
||||
let result = run_before_review_deadline(deadline, external_cancel, future).await;
|
||||
if result.is_err() {
|
||||
cancel_token.cancel();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "deadline_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::run_before_review_deadline_with_cancel;
|
||||
use crate::GuardianReviewSessionOutcome;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn run_before_review_deadline_with_cancel_cancels_token_on_timeout() {
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
||||
let outcome = run_before_review_deadline_with_cancel(
|
||||
tokio::time::Instant::now() + Duration::from_millis(10),
|
||||
/*external_cancel*/ None,
|
||||
&cancel_token,
|
||||
async {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
Err(GuardianReviewSessionOutcome::TimedOut)
|
||||
));
|
||||
assert!(cancel_token.is_cancelled());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn run_before_review_deadline_with_cancel_cancels_token_on_abort() {
|
||||
let external_cancel = CancellationToken::new();
|
||||
let external_canceller = external_cancel.clone();
|
||||
let cancel_token = CancellationToken::new();
|
||||
drop(tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
external_canceller.cancel();
|
||||
}));
|
||||
|
||||
let outcome = run_before_review_deadline_with_cancel(
|
||||
tokio::time::Instant::now() + Duration::from_secs(1),
|
||||
Some(&external_cancel),
|
||||
&cancel_token,
|
||||
std::future::pending::<()>(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
Err(GuardianReviewSessionOutcome::Aborted)
|
||||
));
|
||||
assert!(cancel_token.is_cancelled());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn run_before_review_deadline_with_cancel_preserves_token_on_success() {
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
||||
let outcome = run_before_review_deadline_with_cancel(
|
||||
tokio::time::Instant::now() + Duration::from_secs(1),
|
||||
/*external_cancel*/ None,
|
||||
&cancel_token,
|
||||
async { 42usize },
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome.unwrap(), 42);
|
||||
assert!(!cancel_token.is_cancelled());
|
||||
}
|
||||
@@ -40,7 +40,6 @@ pub const MAX_REVIEW_ATTEMPTS: i64 = 3;
|
||||
pub const REVIEW_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90);
|
||||
|
||||
pub use deadline::run_before_review_deadline;
|
||||
pub(crate) use deadline::run_before_review_deadline_with_cancel;
|
||||
pub use pool::ReviewerPool;
|
||||
pub use pool::ReviewerRequest;
|
||||
pub use pool::ReviewerSession;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Owns the reusable reviewer and temporary forks for one parent thread.
|
||||
//! Guardian supplies agent startup; the host supplies captured context. Selection stays serialized;
|
||||
//! concurrent reviews fork committed context and shutdown joins every tracked session.
|
||||
//! concurrent reviews fork committed context. Lifetime guards cancel agents; ThreadManager
|
||||
//! performs cleanup and tracks its completion. The pool never runs a second shutdown protocol.
|
||||
//! Startup and fork futures stay boxed to bound the orchestration stack frames.
|
||||
|
||||
use std::future::Future;
|
||||
@@ -16,7 +17,6 @@ use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::GuardianReviewSessionOutcome;
|
||||
use crate::run_before_review_deadline;
|
||||
use crate::run_before_review_deadline_with_cancel;
|
||||
|
||||
/// Background work owned by Guardian for one parent runtime.
|
||||
/// Stop cancels the work and joins this tracker before the parent closes its history.
|
||||
@@ -26,18 +26,16 @@ pub struct ReviewerTasks {
|
||||
pub cancellation: CancellationToken,
|
||||
}
|
||||
|
||||
/// A host-owned reviewer session. Context and snapshots remain opaque to the pool.
|
||||
/// Shutdown must cancel the runtime and await its termination.
|
||||
/// Context bookkeeping for a reviewer. Lifetime and cleanup belong to ThreadManager.
|
||||
/// Context and snapshots remain opaque while the host context builder is being A/B tested.
|
||||
pub trait ReviewerSession: Send + Sync + 'static {
|
||||
type Setup: Send + Sync + 'static;
|
||||
type Context: Clone + PartialEq + Send + Sync;
|
||||
type Snapshot: Send + Sync;
|
||||
|
||||
fn context(&self) -> &Self::Context;
|
||||
fn cancel(&self);
|
||||
fn snapshot(&self) -> impl Future<Output = Option<Self::Snapshot>> + Send;
|
||||
fn commit_snapshot(&self) -> impl Future<Output = ()> + Send;
|
||||
fn shutdown(&self) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
/// Executes one approval on a selected session. The host must drain the submitted
|
||||
@@ -74,8 +72,8 @@ pub enum SessionDisposition {
|
||||
|
||||
/// Per-parent reviewer state. The same pool serves prewarm, review, invalidation and shutdown.
|
||||
pub struct ReviewerPool<S: ReviewerSession> {
|
||||
state: Arc<Mutex<PoolState<S>>>,
|
||||
cancellation: CancellationToken,
|
||||
trunk: Mutex<Option<Arc<Trunk<S>>>>,
|
||||
runtime: Arc<ReviewerTasks>,
|
||||
spawn: Box<SpawnReviewer<S>>,
|
||||
}
|
||||
|
||||
@@ -89,20 +87,23 @@ type SpawnReviewer<S> = dyn Fn(
|
||||
+ Send
|
||||
+ Sync;
|
||||
|
||||
struct PoolState<S: ReviewerSession> {
|
||||
trunk: Option<Arc<Trunk<S>>>,
|
||||
ephemeral_reviews: Vec<Arc<S>>,
|
||||
}
|
||||
|
||||
struct Trunk<S: ReviewerSession> {
|
||||
session: Arc<S>,
|
||||
review_lock: Semaphore,
|
||||
cancellation: CancellationToken,
|
||||
}
|
||||
|
||||
impl<S: ReviewerSession> Drop for Trunk<S> {
|
||||
fn drop(&mut self) {
|
||||
self.cancellation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: ReviewerSession> ReviewerPool<S> {
|
||||
/// Installs Guardian's startup function once. It must finish or clean up partial startup
|
||||
/// even when the caller drops its future, and preserve the supplied cancellation token.
|
||||
pub fn new(
|
||||
runtime: Arc<ReviewerTasks>,
|
||||
spawn: impl Fn(
|
||||
Arc<S::Setup>,
|
||||
S::Context,
|
||||
@@ -115,11 +116,8 @@ impl<S: ReviewerSession> ReviewerPool<S> {
|
||||
+ 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(PoolState {
|
||||
trunk: None,
|
||||
ephemeral_reviews: Vec::new(),
|
||||
})),
|
||||
cancellation: CancellationToken::new(),
|
||||
trunk: Mutex::new(None),
|
||||
runtime,
|
||||
spawn: Box::new(spawn),
|
||||
}
|
||||
}
|
||||
@@ -128,17 +126,16 @@ impl<S: ReviewerSession> ReviewerPool<S> {
|
||||
impl<S: ReviewerSession> ReviewerPool<S> {
|
||||
/// Returns the current reviewer handle for host inspection and feedback collection.
|
||||
pub async fn trunk(&self) -> Option<Arc<S>> {
|
||||
self.state
|
||||
self.trunk
|
||||
.lock()
|
||||
.await
|
||||
.trunk
|
||||
.as_ref()
|
||||
.map(|trunk| Arc::clone(&trunk.session))
|
||||
}
|
||||
|
||||
/// Prepares the first reviewer without replacing a review that won the startup race.
|
||||
pub async fn prewarm(&self, setup: Arc<S::Setup>, context: S::Context) -> anyhow::Result<()> {
|
||||
let cancellation = self.cancellation.child_token();
|
||||
let cancellation = self.runtime.cancellation.child_token();
|
||||
let guard = cancellation.clone().drop_guard();
|
||||
let session = (self.spawn)(
|
||||
setup,
|
||||
@@ -148,44 +145,23 @@ impl<S: ReviewerSession> ReviewerPool<S> {
|
||||
cancellation.clone(),
|
||||
)
|
||||
.await?;
|
||||
let mut state = self.state.lock().await;
|
||||
if !cancellation.is_cancelled() && state.trunk.is_none() {
|
||||
state.trunk = Some(Arc::new(Trunk {
|
||||
let mut trunk = self.trunk.lock().await;
|
||||
if !cancellation.is_cancelled() && trunk.is_none() {
|
||||
*trunk = Some(Arc::new(Trunk {
|
||||
session: Arc::new(session),
|
||||
review_lock: Semaphore::new(/*permits*/ 1),
|
||||
cancellation: guard.disarm(),
|
||||
}));
|
||||
drop(guard.disarm());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Permanently stops this parent's reviewer pool and waits for tracked runtimes.
|
||||
pub async fn shutdown(&self) {
|
||||
self.cancellation.cancel();
|
||||
self.invalidate().await;
|
||||
}
|
||||
|
||||
/// Drops reusable context after parent history rollback or another host invalidation.
|
||||
pub async fn invalidate(&self) {
|
||||
let (trunk, ephemeral) = {
|
||||
let mut state = self.state.lock().await;
|
||||
(
|
||||
state.trunk.take(),
|
||||
std::mem::take(&mut state.ephemeral_reviews),
|
||||
)
|
||||
};
|
||||
for session in trunk
|
||||
.into_iter()
|
||||
.map(|trunk| Arc::clone(&trunk.session))
|
||||
.chain(ephemeral)
|
||||
{
|
||||
if self.cancellation.is_cancelled() {
|
||||
session.shutdown().await;
|
||||
} else {
|
||||
session.cancel();
|
||||
shutdown_in_background(session);
|
||||
}
|
||||
}
|
||||
self.runtime.cancellation.cancel();
|
||||
self.trunk.lock().await.take();
|
||||
self.runtime.tasks.close();
|
||||
self.runtime.tasks.wait().await;
|
||||
}
|
||||
|
||||
/// Selects one reviewer; busy or incompatible trunks use an isolated temporary session.
|
||||
@@ -204,26 +180,24 @@ impl<S: ReviewerSession> ReviewerPool<S> {
|
||||
let (trunk, context) = match run_before_review_deadline(
|
||||
request.deadline(),
|
||||
request.cancellation(),
|
||||
self.state.lock(),
|
||||
self.trunk.lock(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut state) => {
|
||||
let context =
|
||||
request.context(state.trunk.as_ref().map(|trunk| trunk.session.as_ref()));
|
||||
if let Some(trunk) = state.trunk.as_ref()
|
||||
&& trunk.session.context() != &context
|
||||
let context = request.context(state.as_ref().map(|trunk| trunk.session.as_ref()));
|
||||
if let Some(trunk) = state.as_ref()
|
||||
&& (trunk.cancellation.is_cancelled() || trunk.session.context() != &context)
|
||||
&& trunk.review_lock.try_acquire().is_ok()
|
||||
&& let Some(stale) = state.trunk.take()
|
||||
{
|
||||
shutdown_in_background(Arc::clone(&stale.session));
|
||||
state.take();
|
||||
}
|
||||
if state.trunk.is_none() {
|
||||
let cancellation = self.cancellation.child_token();
|
||||
let session = match run_before_review_deadline_with_cancel(
|
||||
if state.is_none() {
|
||||
let cancellation = self.runtime.cancellation.child_token();
|
||||
let lifetime = cancellation.clone().drop_guard();
|
||||
let session = match run_before_review_deadline(
|
||||
request.deadline(),
|
||||
request.cancellation(),
|
||||
&cancellation,
|
||||
(self.spawn)(
|
||||
request.setup(),
|
||||
context.clone(),
|
||||
@@ -245,13 +219,14 @@ impl<S: ReviewerSession> ReviewerPool<S> {
|
||||
return (outcome, GuardianReviewAnalyticsResult::without_session());
|
||||
}
|
||||
};
|
||||
state.trunk = Some(Arc::new(Trunk {
|
||||
*state = Some(Arc::new(Trunk {
|
||||
session,
|
||||
review_lock: Semaphore::new(/*permits*/ 1),
|
||||
cancellation: lifetime.disarm(),
|
||||
}));
|
||||
spawned_trunk = true;
|
||||
}
|
||||
(state.trunk.as_ref().cloned(), context)
|
||||
(state.as_ref().cloned(), context)
|
||||
}
|
||||
Err(outcome) => return (outcome, GuardianReviewAnalyticsResult::without_session()),
|
||||
};
|
||||
@@ -282,22 +257,27 @@ impl<S: ReviewerSession> ReviewerPool<S> {
|
||||
} else {
|
||||
GuardianReviewSessionKind::TrunkReused
|
||||
};
|
||||
// Dropping a review before it drains its turn must not leave a reusable agent.
|
||||
let review_lifetime = trunk.cancellation.clone().drop_guard();
|
||||
let (outcome, disposition, analytics) = request.run(&trunk.session, kind).await;
|
||||
if disposition == SessionDisposition::Reusable
|
||||
&& matches!(outcome, GuardianReviewSessionOutcome::Completed(_))
|
||||
{
|
||||
trunk.session.commit_snapshot().await;
|
||||
}
|
||||
if disposition == SessionDisposition::Reusable {
|
||||
review_lifetime.disarm();
|
||||
} else {
|
||||
drop(review_lifetime);
|
||||
}
|
||||
drop(guard);
|
||||
if disposition == SessionDisposition::Discard {
|
||||
let mut state = self.state.lock().await;
|
||||
let mut state = self.trunk.lock().await;
|
||||
if state
|
||||
.trunk
|
||||
.as_ref()
|
||||
.is_some_and(|current| Arc::ptr_eq(current, &trunk))
|
||||
&& let Some(removed) = state.trunk.take()
|
||||
{
|
||||
shutdown_in_background(Arc::clone(&removed.session));
|
||||
state.take();
|
||||
}
|
||||
}
|
||||
(outcome, analytics)
|
||||
@@ -312,11 +292,11 @@ impl<S: ReviewerSession> ReviewerPool<S> {
|
||||
where
|
||||
R: ReviewerRequest<Session = S>,
|
||||
{
|
||||
let cancellation = self.cancellation.child_token();
|
||||
let session = match run_before_review_deadline_with_cancel(
|
||||
let cancellation = self.runtime.cancellation.child_token();
|
||||
let _lifetime = cancellation.clone().drop_guard();
|
||||
let session = match run_before_review_deadline(
|
||||
request.deadline(),
|
||||
request.cancellation(),
|
||||
&cancellation,
|
||||
(self.spawn)(
|
||||
request.setup(),
|
||||
context,
|
||||
@@ -336,63 +316,9 @@ impl<S: ReviewerSession> ReviewerPool<S> {
|
||||
}
|
||||
Err(outcome) => return (outcome, GuardianReviewAnalyticsResult::without_session()),
|
||||
};
|
||||
self.state
|
||||
.lock()
|
||||
.await
|
||||
.ephemeral_reviews
|
||||
.push(Arc::clone(&session));
|
||||
let mut cleanup = EphemeralCleanup {
|
||||
state: Arc::clone(&self.state),
|
||||
session: Some(Arc::clone(&session)),
|
||||
};
|
||||
let (outcome, _, analytics) = request
|
||||
.run(&session, GuardianReviewSessionKind::EphemeralForked)
|
||||
.await;
|
||||
let removed = {
|
||||
let mut state = self.state.lock().await;
|
||||
state
|
||||
.ephemeral_reviews
|
||||
.iter()
|
||||
.position(|active| Arc::ptr_eq(active, &session))
|
||||
.map(|index| state.ephemeral_reviews.swap_remove(index))
|
||||
};
|
||||
if let Some(removed) = removed {
|
||||
cleanup.session = None;
|
||||
shutdown_in_background(removed);
|
||||
}
|
||||
(outcome, analytics)
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown_in_background<S: ReviewerSession>(session: Arc<S>) {
|
||||
drop(tokio::spawn(async move {
|
||||
session.shutdown().await;
|
||||
}));
|
||||
}
|
||||
|
||||
struct EphemeralCleanup<S: ReviewerSession> {
|
||||
state: Arc<Mutex<PoolState<S>>>,
|
||||
session: Option<Arc<S>>,
|
||||
}
|
||||
|
||||
impl<S: ReviewerSession> Drop for EphemeralCleanup<S> {
|
||||
fn drop(&mut self) {
|
||||
let Some(session) = self.session.take() else {
|
||||
return;
|
||||
};
|
||||
let state = Arc::clone(&self.state);
|
||||
drop(tokio::spawn(async move {
|
||||
let removed = {
|
||||
let mut state = state.lock().await;
|
||||
state
|
||||
.ephemeral_reviews
|
||||
.iter()
|
||||
.position(|active| Arc::ptr_eq(active, &session))
|
||||
.map(|index| state.ephemeral_reviews.swap_remove(index))
|
||||
};
|
||||
if let Some(removed) = removed {
|
||||
removed.shutdown().await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,4 +38,5 @@ uuid = { workspace = true }
|
||||
codex-model-provider-info = { workspace = true }
|
||||
core_test_support = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
@@ -9,7 +9,7 @@ use codex_login::AuthManager;
|
||||
mod async_scorer;
|
||||
mod sync_reviewer;
|
||||
|
||||
pub use sync_reviewer::GuardianExtension as GuardianReviewerExtension;
|
||||
pub use sync_reviewer::install as install_reviewer;
|
||||
|
||||
/// Installs the guardian contributors into the extension registry.
|
||||
pub fn install(
|
||||
@@ -18,5 +18,5 @@ pub fn install(
|
||||
thread_manager: Weak<ThreadManager>,
|
||||
) {
|
||||
async_scorer::install(registry, auth_manager, thread_manager.clone());
|
||||
sync_reviewer::install(registry, thread_manager);
|
||||
install_reviewer(registry, thread_manager);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Exercises Guardian registration and denial cleanup through real turns.
|
||||
|
||||
use super::*;
|
||||
use codex_core::TurnInputRequest;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::streaming_sse::StreamingSseChunk;
|
||||
use core_test_support::streaming_sse::start_streaming_sse_server;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::time::Duration;
|
||||
|
||||
struct StaleDenials(ModelInfo);
|
||||
|
||||
impl TurnLifecycleContributor for StaleDenials {
|
||||
fn on_turn_start<'a>(&'a self, input: TurnStartInput<'a>) -> ExtensionFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
let denials = ReviewDenials::for_thread(input.thread_store);
|
||||
for _ in 0..2 {
|
||||
assert_eq!(denials.record_denial(input.turn_id, &self.0).await, None);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn terminal_turn_clears_extension_owned_denials() -> anyhow::Result<()> {
|
||||
let home = tempfile::tempdir()?;
|
||||
let config = core_test_support::load_default_config_for_test(&home).await;
|
||||
let model = codex_core::test_support::construct_model_info_offline("gpt-5", &config);
|
||||
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
|
||||
// Seed stale state before the production Guardian contributor runs its start hook.
|
||||
extensions.turn_lifecycle_contributor(Arc::new(StaleDenials(model.clone())));
|
||||
let mut releases = Vec::new();
|
||||
let streams = (0..3)
|
||||
.map(|_| {
|
||||
let (release, gate) = tokio::sync::oneshot::channel();
|
||||
releases.push(release);
|
||||
vec![
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse(vec![ev_response_created("response")]),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: Some(gate),
|
||||
body: sse(vec![ev_completed("response")]),
|
||||
},
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let (streaming, _) = start_streaming_sse_server(streams).await;
|
||||
let server = start_mock_server().await;
|
||||
let base_url = format!("{}/v1", streaming.uri());
|
||||
let test = test_codex()
|
||||
.with_extensions(Arc::new(extensions.build()))
|
||||
.with_config(move |config| {
|
||||
config.model_provider.base_url = Some(base_url);
|
||||
config.model_provider.supports_websockets = false;
|
||||
})
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
let denials = ReviewDenials::for_thread(test.codex.thread_extension_data());
|
||||
|
||||
let mut previous_turn = None;
|
||||
// The third turn also checks that an interrupted turn leaves the next turn usable.
|
||||
for (index, release) in releases.into_iter().enumerate() {
|
||||
test.codex
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||
text: "Continue.".into(),
|
||||
text_elements: Vec::new(),
|
||||
}]))
|
||||
.await?;
|
||||
let turn_id = wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::TurnStarted(event) => Some(event.turn_id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
// An interruption event can precede its cleanup. The next turn starts after it.
|
||||
if let Some(previous) = previous_turn.replace(turn_id.clone()) {
|
||||
assert_eq!(denials.record_denial(&previous, &model).await, None);
|
||||
}
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(/*secs*/ 10),
|
||||
streaming.wait_for_request_count(index + 1),
|
||||
)
|
||||
.await?;
|
||||
// Without start cleanup, these denials reach the three-denial limit.
|
||||
for _ in 0..2 {
|
||||
assert_eq!(denials.record_denial(&turn_id, &model).await, None);
|
||||
}
|
||||
if index == 1 {
|
||||
test.codex.submit(Op::Interrupt).await?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnAborted(event)
|
||||
if event.reason == TurnAbortReason::Interrupted
|
||||
&& event.turn_id.as_deref() == Some(turn_id.as_str()))
|
||||
})
|
||||
.await;
|
||||
drop(release);
|
||||
} else {
|
||||
release.send(()).unwrap();
|
||||
wait_for_event(
|
||||
&test.codex,
|
||||
|event| matches!(event, EventMsg::TurnComplete(event) if event.turn_id == turn_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
test.codex.shutdown_and_wait().await?;
|
||||
assert_eq!(
|
||||
denials.record_denial(&previous_turn.unwrap(), &model).await,
|
||||
None
|
||||
);
|
||||
streaming.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,6 +16,11 @@ use codex_extension_api::ThreadLifecycleContributor;
|
||||
use codex_extension_api::ThreadReadyInput;
|
||||
use codex_extension_api::ThreadStartInput;
|
||||
use codex_extension_api::ThreadStopInput;
|
||||
use codex_extension_api::TurnAbortInput;
|
||||
use codex_extension_api::TurnLifecycleContributor;
|
||||
use codex_extension_api::TurnStartInput;
|
||||
use codex_extension_api::TurnStopInput;
|
||||
use codex_guardian_reviewer::ReviewDenials;
|
||||
use codex_guardian_reviewer::ReviewerPool;
|
||||
use codex_guardian_reviewer::ReviewerTasks;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -26,17 +31,11 @@ use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
|
||||
/// Owns reviewer agents through the same thread manager as the parent conversation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GuardianExtension {
|
||||
#[derive(Debug)]
|
||||
struct GuardianExtension {
|
||||
thread_manager: Weak<ThreadManager>,
|
||||
}
|
||||
|
||||
impl GuardianExtension {
|
||||
pub fn new(thread_manager: Weak<ThreadManager>) -> Self {
|
||||
Self { thread_manager }
|
||||
}
|
||||
}
|
||||
|
||||
impl ThreadLifecycleContributor<Config> for GuardianExtension {
|
||||
fn on_thread_start<'a>(
|
||||
&'a self,
|
||||
@@ -50,6 +49,7 @@ impl ThreadLifecycleContributor<Config> for GuardianExtension {
|
||||
let runtime = input.thread_store.get_or_init(ReviewerTasks::default);
|
||||
input.thread_store.get_or_init(|| {
|
||||
ReviewerPool::<GuardianReviewSession>::new(
|
||||
Arc::clone(&runtime),
|
||||
move |context, key, kind, snapshot, cancel| {
|
||||
let manager = manager.clone();
|
||||
let runtime = Arc::clone(&runtime);
|
||||
@@ -82,14 +82,12 @@ impl ThreadLifecycleContributor<Config> for GuardianExtension {
|
||||
options
|
||||
.thread_extension_init
|
||||
.insert(SessionIsolation::Isolated);
|
||||
let parent_cancel = runtime.cancellation.clone();
|
||||
let session_cancel = cancel.clone();
|
||||
let until = async move {
|
||||
let _cancel_on_exit = cancel.clone().drop_guard();
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => {}
|
||||
_ = history_reset.cancelled() => {}
|
||||
_ = parent_cancel.cancelled() => {}
|
||||
}
|
||||
};
|
||||
let spawned = manager
|
||||
@@ -164,24 +162,44 @@ impl ThreadLifecycleContributor<Config> for GuardianExtension {
|
||||
|
||||
fn on_thread_stop<'a>(&'a self, input: ThreadStopInput<'a>) -> ExtensionFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
if let Some(runtime) = input.thread_store.get::<ReviewerTasks>() {
|
||||
runtime.cancellation.cancel();
|
||||
if let Some(pool) = input
|
||||
.thread_store
|
||||
.get::<ReviewerPool<GuardianReviewSession>>()
|
||||
{
|
||||
pool.shutdown().await;
|
||||
}
|
||||
runtime.tasks.close();
|
||||
runtime.tasks.wait().await;
|
||||
if let Some(pool) = input
|
||||
.thread_store
|
||||
.get::<ReviewerPool<GuardianReviewSession>>()
|
||||
{
|
||||
pool.shutdown().await;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TurnLifecycleContributor for GuardianExtension {
|
||||
fn on_turn_start<'a>(&'a self, input: TurnStartInput<'a>) -> ExtensionFuture<'a, ()> {
|
||||
Box::pin(ReviewDenials::clear_turn(input.thread_store, input.turn_id))
|
||||
}
|
||||
fn on_turn_stop<'a>(&'a self, input: TurnStopInput<'a>) -> ExtensionFuture<'a, ()> {
|
||||
Box::pin(ReviewDenials::clear_turn(
|
||||
input.thread_store,
|
||||
input.turn_store.level_id(),
|
||||
))
|
||||
}
|
||||
fn on_turn_abort<'a>(&'a self, input: TurnAbortInput<'a>) -> ExtensionFuture<'a, ()> {
|
||||
Box::pin(ReviewDenials::clear_turn(
|
||||
input.thread_store,
|
||||
input.turn_store.level_id(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the synchronous reviewer and its thread and turn cleanup.
|
||||
pub fn install(
|
||||
registry: &mut ExtensionRegistryBuilder<Config>,
|
||||
thread_manager: Weak<ThreadManager>,
|
||||
) {
|
||||
registry.thread_lifecycle_contributor(Arc::new(GuardianExtension::new(thread_manager)));
|
||||
let extension = Arc::new(GuardianExtension { thread_manager });
|
||||
registry.thread_lifecycle_contributor(extension.clone());
|
||||
registry.turn_lifecycle_contributor(extension);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lifecycle_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user