Move Guardian reviewer lifecycle into the extension (#45537)

## Why

Guardian reviews must stop when their parent shuts down or their history is reset. Shutdown must also finish reviewer cleanup before closing the parent's persistent history, including when a review is waiting to retry after a rate limit.

## What changed

- Let the Guardian extension own reviewer startup, prewarming, and shutdown through `ThreadManager`, tracking and joining outstanding work during teardown.
- Cancel reviews on history reset or parent shutdown and reject decisions returned after cancellation, including cached extension decisions.
- Keep background prewarm previews from overwriting the active turn's model metadata.

## Testing

Extend regression coverage to verify reviewer cleanup after parent shutdown, prompt shutdown during a 60-second Guardian retry with the network request denied, and preservation of active model review requirements during prewarming.

GitOrigin-RevId: 05efc369b9a1642a10365eac8a09da77f3f6e28c
This commit is contained in:
jif authored and copyberry committed 2026-09-14 22:58:51 +00:00
1 parent 12b0164a48
commit 18d7ace221
31 files changed
+495 -308

No files matched your search

+2 -5
View File
@@ -308,12 +308,12 @@ pub(super) async fn shutdown_session_runtime(sess: &Arc<Session>) {
sess.mcp_refresh.close();
sess.services.mcp_runtime.shutdown().await;
}
sess.guardian_review_session().shutdown().await;
crate::hook_runtime::run_session_end_hooks(sess).await;
emit_thread_stop_lifecycle(sess).await;
}
pub(super) async fn emit_thread_stop_lifecycle(sess: &Session) {
async fn emit_thread_stop_lifecycle(sess: &Session) {
for contributor in sess.services.extensions.thread_lifecycle_contributors() {
contributor
.on_thread_stop(codex_extension_api::ThreadStopInput {
@@ -338,8 +338,6 @@ pub async fn shutdown(sess: &Arc<Session>, sub_id: String) -> bool {
&[],
);
emit_thread_stop_lifecycle(sess.as_ref()).await;
// Gracefully flush and shutdown thread persistence on session end so tests
// that inspect durable state do not race with the background writer.
if let Some(live_thread) = sess.live_thread()
@@ -607,7 +605,6 @@ pub(super) async fn submission_loop(
// explicit shutdown op, still run session teardown.
if !shutdown_received {
shutdown_session_runtime(&sess).await;
emit_thread_stop_lifecycle(sess.as_ref()).await;
if let Some(live_thread) = sess.live_thread()
&& let Err(err) = live_thread.shutdown().await
{
+14 -7
View File
@@ -2245,13 +2245,11 @@ impl Session {
));
}
/// Uses the extension-owned reviewer, or the same manager for standalone hosts.
pub(crate) fn guardian_review_session(&self) -> Arc<GuardianReviewSessionManager> {
self.services.thread_extension_data.get_or_init(|| {
GuardianReviewSessionManager::new(|context, key, kind, snapshot, cancel| {
Box::pin(async move { context.spawn(key, kind, snapshot, cancel).await })
})
})
/// Returns the reviewer pool installed by the Guardian extension.
pub(crate) fn guardian_review_session(&self) -> Option<Arc<GuardianReviewSessionManager>> {
self.services
.thread_extension_data
.get::<GuardianReviewSessionManager>()
}
pub(crate) async fn emit_turn_started(&self, turn_context: &TurnContext) {
@@ -4393,6 +4391,15 @@ impl Session {
state.clone_history()
}
/// Captures the history lifetime without exposing mutable session state.
pub(crate) async fn history_reset(&self) -> (u64, CancellationToken) {
let state = self.state.lock().await;
(
state.history.reset_version,
state.history_reset.child_token(),
)
}
pub(crate) async fn conversation_history_snapshot(
&self,
) -> Arc<dyn ConversationHistorySnapshot> {
+4 -4
View File
@@ -1021,12 +1021,12 @@ impl Session {
self.features.enabled(Feature::Personality),
)
.await;
self.services
.thread_extension_data
.insert(model_info.clone());
let multi_agent_version = match multi_agent_runtime {
TurnMultiAgentRuntime::ResolveAndStore => {
// A background preview must not overwrite a newer turn's model metadata.
self.services
.thread_extension_data
.insert(model_info.clone());
self.resolve_multi_agent_version_for_model(&model_info, &per_turn_config)
}
TurnMultiAgentRuntime::Preview => per_turn_config.multi_agent_version_for_model(
+2 -3
View File
@@ -107,9 +107,8 @@ pub(super) async fn suspend_turn_and_shutdown(
live_thread.shutdown().await.map_err(|error| {
CodexErr::Fatal(format!("close suspended root turn writer failed: {error}"))
})?;
// Announce thread shutdown only after its writer closes so a replacement worker
// cannot write the same thread concurrently.
handlers::emit_thread_stop_lifecycle(session.as_ref()).await;
// Announce completion only after extension cleanup and writer closure so a
// replacement worker cannot write the same thread concurrently.
session
.deliver_event_raw(Event {
id: submission_id,