Skip to content

Commit 62c7f50

Browse files
authored
[codex] poll external clock during sleep (#30113)
## Summary - make the external app-server time provider establish sleep deadlines using `currentTime/read` - poll the external clock once per second and complete `clock.sleep` when the deadline is reached - keep the system-clock timer and existing steer/agent-message interruption behavior unchanged ## Why This lets training control `clock.sleep` through its existing external simulated clock without adding separate sleep/wake protocol methods. ## Testing - `just fmt` - `just test -p codex-app-server external_sleep_polls_current_time_and_emits_items`
1 parent 3b22498 commit 62c7f50

2 files changed

Lines changed: 106 additions & 38 deletions

File tree

codex-rs/app-server/src/current_time.rs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use crate::outgoing_message::OutgoingMessageSender;
2323
use crate::thread_state::ThreadStateManager;
2424

2525
const CURRENT_TIME_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
26+
const CURRENT_TIME_POLL_INTERVAL: Duration = Duration::from_secs(1);
2627

2728
pub(crate) fn app_server_time_provider(
2829
outgoing: Arc<OutgoingMessageSender>,
@@ -51,10 +52,32 @@ impl TimeProvider for AppServerTimeProvider {
5152
})
5253
}
5354

54-
fn sleep(&self, _thread_id: ThreadId, duration: Duration) -> SleepFuture<'_> {
55+
fn sleep(&self, thread_id: ThreadId, duration: Duration) -> SleepFuture<'_> {
56+
let outgoing = self.outgoing.clone();
57+
let thread_state_manager = self.thread_state_manager.clone();
5558
Box::pin(async move {
56-
tokio::time::sleep(duration).await;
57-
Ok(())
59+
let outgoing = outgoing
60+
.upgrade()
61+
.context("app-server current-time provider is unavailable")?;
62+
let started_at =
63+
request_current_time(outgoing.clone(), thread_state_manager.clone(), thread_id)
64+
.await?;
65+
let wake_at = started_at
66+
.checked_add_signed(
67+
chrono::Duration::from_std(duration)
68+
.context("external sleep duration is outside the supported range")?,
69+
)
70+
.context("external sleep deadline is outside the supported range")?;
71+
72+
loop {
73+
tokio::time::sleep(CURRENT_TIME_POLL_INTERVAL).await;
74+
if request_current_time(outgoing.clone(), thread_state_manager.clone(), thread_id)
75+
.await?
76+
>= wake_at
77+
{
78+
return Ok(());
79+
}
80+
}
5881
})
5982
}
6083
}

codex-rs/app-server/tests/suite/v2/sleep.rs

Lines changed: 80 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
use anyhow::Result;
22
use app_test_support::TestAppServer;
33
use app_test_support::to_response;
4+
use codex_app_server_protocol::CurrentTimeReadResponse;
45
use codex_app_server_protocol::ItemCompletedNotification;
56
use codex_app_server_protocol::ItemStartedNotification;
6-
use codex_app_server_protocol::JSONRPCMessage;
77
use codex_app_server_protocol::JSONRPCResponse;
88
use codex_app_server_protocol::RequestId;
9+
use codex_app_server_protocol::ServerRequest;
910
use codex_app_server_protocol::ThreadItem;
1011
use codex_app_server_protocol::ThreadStartParams;
1112
use codex_app_server_protocol::ThreadStartResponse;
@@ -19,12 +20,16 @@ use std::time::Duration;
1920
use tempfile::TempDir;
2021
use tokio::time::timeout;
2122

23+
#[cfg(windows)]
24+
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(25);
25+
#[cfg(not(windows))]
2226
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
27+
const CURRENT_TIME_AT: i64 = 1_781_717_655;
2328

2429
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
25-
async fn sleep_emits_started_and_completed_items() -> Result<()> {
30+
async fn external_sleep_polls_current_time_and_emits_items() -> Result<()> {
2631
const CALL_ID: &str = "sleep-1";
27-
const DURATION_MS: u64 = 1;
32+
const DURATION_MS: u64 = 2_000;
2833

2934
let server = responses::start_mock_server().await;
3035
responses::mount_sse_sequence(
@@ -85,38 +90,19 @@ async fn sleep_emits_started_and_completed_items() -> Result<()> {
8590
.await??;
8691
let TurnStartResponse { turn, .. } = to_response(turn_start_response)?;
8792

88-
let (started, completed) = timeout(DEFAULT_READ_TIMEOUT, async {
89-
let mut started = None;
90-
let mut completed = None;
91-
while started.is_none() || completed.is_none() {
92-
let JSONRPCMessage::Notification(notification) = mcp.read_next_message().await? else {
93-
continue;
94-
};
95-
match notification.method.as_str() {
96-
"item/started" => {
97-
let payload: ItemStartedNotification =
98-
serde_json::from_value(notification.params.expect("item/started params"))?;
99-
if matches!(&payload.item, ThreadItem::Sleep { .. }) {
100-
started = Some(payload);
101-
}
102-
}
103-
"item/completed" => {
104-
let payload: ItemCompletedNotification = serde_json::from_value(
105-
notification.params.expect("item/completed params"),
106-
)?;
107-
if matches!(&payload.item, ThreadItem::Sleep { .. }) {
108-
completed = Some(payload);
109-
}
110-
}
111-
_ => {}
112-
}
113-
}
114-
Ok::<_, anyhow::Error>((
115-
started.expect("sleep started"),
116-
completed.expect("sleep completed"),
117-
))
118-
})
119-
.await??;
93+
// Read once for the initial reminder, then once to establish the sleep deadline.
94+
respond_to_current_time_read(&mut mcp, &thread.id, CURRENT_TIME_AT).await?;
95+
let started = wait_for_sleep_started(&mut mcp, CALL_ID).await?;
96+
respond_to_current_time_read(&mut mcp, &thread.id, CURRENT_TIME_AT).await?;
97+
98+
// The first poll remains below the deadline, so the provider must request time again.
99+
respond_to_current_time_read(&mut mcp, &thread.id, CURRENT_TIME_AT + 1).await?;
100+
respond_to_current_time_read(&mut mcp, &thread.id, CURRENT_TIME_AT + 2).await?;
101+
102+
let completed = wait_for_sleep_completed(&mut mcp, CALL_ID).await?;
103+
104+
// The next inference boundary reads the same external clock after the sleep completes.
105+
respond_to_current_time_read(&mut mcp, &thread.id, CURRENT_TIME_AT + 2).await?;
120106
timeout(
121107
DEFAULT_READ_TIMEOUT,
122108
mcp.read_stream_until_notification_message("turn/completed"),
@@ -150,6 +136,64 @@ async fn sleep_emits_started_and_completed_items() -> Result<()> {
150136
Ok(())
151137
}
152138

139+
async fn wait_for_sleep_started(
140+
mcp: &mut TestAppServer,
141+
call_id: &str,
142+
) -> Result<ItemStartedNotification> {
143+
loop {
144+
let notification = timeout(
145+
DEFAULT_READ_TIMEOUT,
146+
mcp.read_stream_until_notification_message("item/started"),
147+
)
148+
.await??;
149+
let started: ItemStartedNotification =
150+
serde_json::from_value(notification.params.expect("item/started params"))?;
151+
if matches!(&started.item, ThreadItem::Sleep { id, .. } if id == call_id) {
152+
return Ok(started);
153+
}
154+
}
155+
}
156+
157+
async fn wait_for_sleep_completed(
158+
mcp: &mut TestAppServer,
159+
call_id: &str,
160+
) -> Result<ItemCompletedNotification> {
161+
loop {
162+
let notification = timeout(
163+
DEFAULT_READ_TIMEOUT,
164+
mcp.read_stream_until_notification_message("item/completed"),
165+
)
166+
.await??;
167+
let completed: ItemCompletedNotification =
168+
serde_json::from_value(notification.params.expect("item/completed params"))?;
169+
if matches!(&completed.item, ThreadItem::Sleep { id, .. } if id == call_id) {
170+
return Ok(completed);
171+
}
172+
}
173+
}
174+
175+
async fn respond_to_current_time_read(
176+
mcp: &mut TestAppServer,
177+
thread_id: &str,
178+
current_time_at: i64,
179+
) -> Result<()> {
180+
let request = timeout(
181+
DEFAULT_READ_TIMEOUT,
182+
mcp.read_stream_until_request_message(),
183+
)
184+
.await??;
185+
let ServerRequest::CurrentTimeRead { request_id, params } = request else {
186+
panic!("expected CurrentTimeRead request, got: {request:?}");
187+
};
188+
assert_eq!(params.thread_id, thread_id);
189+
mcp.send_response(
190+
request_id,
191+
serde_json::to_value(CurrentTimeReadResponse { current_time_at })?,
192+
)
193+
.await?;
194+
Ok(())
195+
}
196+
153197
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
154198
std::fs::write(
155199
codex_home.join("config.toml"),
@@ -170,6 +214,7 @@ stream_max_retries = 0
170214
[features.current_time_reminder]
171215
enabled = true
172216
sleep_tool = true
217+
clock_source = "external"
173218
"#
174219
),
175220
)

0 commit comments

Comments
 (0)