Add opt-in nonfatal handling for clock read failures (#45825)

## Why

Clock provider failures can abort a turn while preparing time context or running clock tools. Allow turns to continue with an explicit indication that the current time is unavailable.

## What changed

- Add `features.nonfatal_clock_read_errors`, disabled by default, to report clock failures to the model without failing the turn.
- Emit a generic `failed to read current time` notice for context reads and tool errors, without exposing provider error details. Deduplicate context notices per turn and compaction window, and remove inherited notices from forked subagent context.
- Omit unavailable environment dates and explicitly clear previously visible dates with `<current_date status="unavailable" />`.
- Return external sleep clock failures to the model when the feature is enabled, preserving sleep item completion notifications.

## Testing

Add coverage for continued inference after clock failures, notice deduplication across compaction, subagent notice filtering, environment date removal and recovery, and sleep failures during initial and polling reads.

GitOrigin-RevId: a39c3723c06a6f786d8ad59d667e8b9f626c973e
This commit is contained in:
Vivian Fang
2026-09-16 01:16:35 +00:00
committed by copyberry
parent fac58c1153
commit 5bf132cd52
14 changed files with 367 additions and 48 deletions
+55 -13
View File
@@ -5,19 +5,23 @@ use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CurrentTimeReadResponse;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SleepItem;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput as V2UserInput;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::time::Duration;
use tempfile::TempDir;
use test_case::test_case;
use tokio::time::timeout;
use super::analytics::captured_analytics_events;
@@ -159,13 +163,19 @@ async fn clock_tools_emit_control_tool_analytics() -> Result<()> {
Ok(())
}
#[test_case(0; "initial read fails")]
#[test_case(1; "polling read fails")]
#[test_case(3; "successful reads")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn external_sleep_polls_current_time_and_emits_items() -> Result<()> {
async fn external_sleep_polls_current_time_and_emits_items(
successful_sleep_reads: usize,
) -> Result<()> {
const CALL_ID: &str = "sleep-1";
const DURATION_MS: u64 = 2_000;
let read_fails = successful_sleep_reads < 3;
let server = responses::start_mock_server().await;
responses::mount_sse_sequence(
let model = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
@@ -189,13 +199,15 @@ async fn external_sleep_polls_current_time_and_emits_items() -> Result<()> {
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri())
.with_root_config("include_environment_context = false")
.with_extra_config(
r#"[features.current_time_reminder]
.with_extra_config(&format!(
r#"[features]
nonfatal_clock_read_errors = {read_fails}
[features.current_time_reminder]
enabled = true
sleep_tool = true
clock_source = "external"
"#,
)
))
.write(codex_home.path())?;
let mut mcp = TestAppServer::builder()
@@ -228,21 +240,51 @@ clock_source = "external"
// Read once for the initial reminder, then once to establish the sleep deadline.
respond_to_current_time_read(&mut mcp, &thread.id, CURRENT_TIME_AT).await?;
let started = wait_for_sleep_started(&mut mcp, CALL_ID).await?;
respond_to_current_time_read(&mut mcp, &thread.id, CURRENT_TIME_AT).await?;
// The first poll remains below the deadline, so the provider must request time again.
respond_to_current_time_read(&mut mcp, &thread.id, CURRENT_TIME_AT + 1).await?;
respond_to_current_time_read(&mut mcp, &thread.id, CURRENT_TIME_AT + 2).await?;
for current_time_at in [CURRENT_TIME_AT, CURRENT_TIME_AT + 1, CURRENT_TIME_AT + 2]
.into_iter()
.take(successful_sleep_reads)
{
respond_to_current_time_read(&mut mcp, &thread.id, current_time_at).await?;
}
if read_fails {
let request = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_request_message(),
)
.await??;
let ServerRequest::CurrentTimeRead { request_id, params } = request else {
panic!("expected clock read, got {request:?}");
};
assert_eq!(params.thread_id, thread.id);
mcp.send_error(
request_id,
JSONRPCErrorError {
code: -32_000,
message: "PRIVATE_CLOCK_ERROR".to_string(),
data: None,
},
)
.await?;
}
let completed = wait_for_sleep_completed(&mut mcp, CALL_ID).await?;
// The next inference boundary reads the same external clock after the sleep completes.
// The next inference boundary reads the same external clock after the tool returns.
respond_to_current_time_read(&mut mcp, &thread.id, CURRENT_TIME_AT + 2).await?;
timeout(
let turn_completed: TurnCompletedNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
mcp.read_notification("turn/completed"),
)
.await??;
assert_eq!(turn_completed.turn.status, TurnStatus::Completed);
if read_fails {
let requests = model.requests();
assert_eq!(requests.len(), 2);
assert_eq!(
requests[1].function_call_output_text(CALL_ID).as_deref(),
Some("failed to read current time")
);
}
let expected_item = ThreadItem::Sleep(SleepItem {
id: CALL_ID.to_string(),