[codex] Preserve reviewer when resuming threads (#30278)

## Why

A thread resumed without an explicit reviewer could pick up the reviewer
from the current config instead of preserving the reviewer already in
use by the thread. After an app restart, this meant a thread running
with auto review could silently switch back to user review, and the next
turn could continue under the wrong reviewer.

## What changed

Persist the effective reviewer with each turn and restore the latest
persisted value when the thread resumes. If the resume request
explicitly provides a reviewer, that value still takes precedence.

## Test plan

- Added a regression test that starts a thread with auto review, records
a turn, restarts with user review in config, resumes without an
override, and verifies that auto review is preserved.
- `just test -p codex-protocol`
- `just test -p codex-state`
- `just test -p codex-rollout`
- `just test -p codex-app-server
thread_resume_preserves_persisted_approvals_reviewer`
- Clippy for the affected crates
This commit is contained in:
viyatb-oai
2026-07-08 17:58:28 -07:00
committed by GitHub
parent 3fa90665fe
commit 0746e8a345
15 changed files with 347 additions and 24 deletions

View File

@@ -206,6 +206,7 @@ struct ExecRunArgs {
state_db: Option<StateDbHandle>,
command: Option<ExecCommand>,
config: Config,
resume_approvals_reviewer_override: Option<codex_app_server_protocol::ApprovalsReviewer>,
dangerously_bypass_approvals_and_sandbox: bool,
exec_span: tracing::Span,
images: Vec<PathBuf>,
@@ -463,6 +464,10 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
build_config,
)
.await?;
let resume_approvals_reviewer_override = cli_kv_overrides
.iter()
.any(|(key, _)| key == "approvals_reviewer")
.then(|| config.approvals_reviewer.into());
#[allow(clippy::print_stderr)]
match check_execpolicy_for_warnings(&config.config_layer_stack).await {
@@ -576,6 +581,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
state_db,
command,
config,
resume_approvals_reviewer_override,
dangerously_bypass_approvals_and_sandbox,
exec_span: exec_span.clone(),
images,
@@ -673,6 +679,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
state_db,
command,
config,
resume_approvals_reviewer_override,
dangerously_bypass_approvals_and_sandbox,
exec_span,
images,
@@ -804,7 +811,11 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
&client,
ClientRequest::ThreadResume {
request_id: request_ids.next(),
params: thread_resume_params_from_config(&config, thread_id),
params: thread_resume_params_from_config(
&config,
thread_id,
resume_approvals_reviewer_override,
),
},
"thread/resume",
)
@@ -1066,7 +1077,7 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams {
cwd: Some(config.cwd.to_string_lossy().to_string()),
runtime_workspace_roots: Some(config.workspace_roots.clone()),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: approvals_reviewer_override_from_config(config),
approvals_reviewer: Some(config.approvals_reviewer.into()),
sandbox: sandbox.flatten(),
permissions,
config: thread_config_overrides_from_config(config),
@@ -1076,7 +1087,11 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams {
}
}
fn thread_resume_params_from_config(config: &Config, thread_id: String) -> ThreadResumeParams {
fn thread_resume_params_from_config(
config: &Config,
thread_id: String,
approvals_reviewer_override: Option<codex_app_server_protocol::ApprovalsReviewer>,
) -> ThreadResumeParams {
let permissions = permissions_selection_from_config(config);
let sandbox = permissions.is_none().then(|| {
sandbox_mode_from_permission_profile(
@@ -1091,7 +1106,7 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa
cwd: Some(config.cwd.to_string_lossy().to_string()),
runtime_workspace_roots: Some(config.workspace_roots.clone()),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: approvals_reviewer_override_from_config(config),
approvals_reviewer: approvals_reviewer_override,
sandbox: sandbox.flatten(),
permissions,
config: thread_config_overrides_from_config(config),
@@ -1141,12 +1156,6 @@ fn sandbox_mode_from_permission_profile(
}
}
fn approvals_reviewer_override_from_config(
config: &Config,
) -> Option<codex_app_server_protocol::ApprovalsReviewer> {
Some(config.approvals_reviewer.into())
}
async fn send_request_with_response<T>(
client: &InProcessAppServerClient,
request: ClientRequest,

View File

@@ -491,6 +491,39 @@ async fn thread_start_params_include_review_policy_when_auto_review_is_enabled()
);
}
#[tokio::test]
async fn thread_resume_params_only_include_explicit_review_policy_override() {
let codex_home = tempdir().expect("create temp codex home");
let cwd = tempdir().expect("create temp cwd");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
..Default::default()
})
.fallback_cwd(Some(cwd.path().to_path_buf()))
.build()
.await
.expect("build config with guardian review policy");
let params_without_override = thread_resume_params_from_config(
&config,
"thread-id".to_string(),
/*approvals_reviewer_override*/ None,
);
let params_with_override = thread_resume_params_from_config(
&config,
"thread-id".to_string(),
Some(codex_app_server_protocol::ApprovalsReviewer::AutoReview),
);
assert_eq!(params_without_override.approvals_reviewer, None);
assert_eq!(
params_with_override.approvals_reviewer,
Some(codex_app_server_protocol::ApprovalsReviewer::AutoReview)
);
}
#[tokio::test]
async fn build_exec_config_retries_without_invalid_headless_policy_for_auto_review() {
let codex_home = tempdir().expect("create temp codex home");
@@ -616,7 +649,11 @@ async fn thread_lifecycle_params_preserve_hook_trust_bypass() {
)]));
let start_params = thread_start_params_from_config(&config);
let resume_params = thread_resume_params_from_config(&config, "thread-id".to_string());
let resume_params = thread_resume_params_from_config(
&config,
"thread-id".to_string(),
/*approvals_reviewer_override*/ None,
);
assert_eq!(start_params.config, expected_config);
assert_eq!(resume_params.config, expected_config);
@@ -648,7 +685,11 @@ async fn thread_lifecycle_params_include_legacy_sandbox_when_no_active_profile()
.expect("build config with legacy sandbox override");
let start_params = thread_start_params_from_config(&config);
let resume_params = thread_resume_params_from_config(&config, "thread-id".to_string());
let resume_params = thread_resume_params_from_config(
&config,
"thread-id".to_string(),
/*approvals_reviewer_override*/ None,
);
assert_eq!(config.permissions.active_permission_profile(), None);
assert_eq!(