Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
49530f6
Fix crash-safe scheduled occurrences
andrei-hasna Aug 7, 2026
caf083f
test(state): expose occurrence finalization fence
andrei-hasna Aug 8, 2026
d28191b
test(state): disambiguate occurrence assertions
andrei-hasna Aug 8, 2026
6497f23
fix(state): preserve terminal occurrence fencing
andrei-hasna Aug 8, 2026
09d2d8a
fix(app-server): expose occurrence terminal helpers
andrei-hasna Aug 8, 2026
df1f662
fix(app-server): widen occurrence re-export hop
andrei-hasna Aug 8, 2026
8935e06
test(loop): link occurrence regressions conventionally
andrei-hasna Aug 8, 2026
998f86c
test(loop): compile relinked regressions
andrei-hasna Aug 8, 2026
a5a681a
test(loop): satisfy argument comment lint
andrei-hasna Aug 8, 2026
7a9e227
fix(loop): honor occurrence terminal lifecycle
andrei-hasna Aug 8, 2026
fc317a0
test(state): satisfy occurrence argument lint
andrei-hasna Aug 8, 2026
d1c9b83
fix(core): serialize task start mailbox snapshot
andrei-hasna Aug 8, 2026
f77d901
fix(state): fence terminal writes by lease expiry
andrei-hasna Aug 8, 2026
2d28c89
test(app-server): keep scheduled-run lease current
andrei-hasna Aug 8, 2026
1f0597f
test(app-server): assemble credential fixtures at runtime
andrei-hasna Aug 8, 2026
9c36376
Fix exact-head Bazel clippy failures
andrei-hasna Aug 8, 2026
7374507
chore(state): renumber occurrence migration
andrei-hasna Aug 9, 2026
6fa9939
fix(monitor): require current-fence authorization
andrei-hasna Aug 9, 2026
840814b
fix(monitor): expose current session snapshot
andrei-hasna Aug 9, 2026
0af635e
fix(app-server): use configured monitor environment
andrei-hasna Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions codex-rs/app-server/src/message_processor_schedule_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,38 @@ impl ScheduleHarness {
})
.await?
.expect("schedule should claim for seeded failure");
let turn_id = claim
.run
.turn_id
.as_deref()
.expect("claimed occurrence should have a stable turn id");
self.state_db
.thread_schedules()
.enqueue_thread_schedule_run(codex_state::ThreadScheduleRunEnqueueParams {
schedule_id,
run_id: claim.run.run_id.as_str(),
lease_id: claim.run.lease_id.as_str(),
goal_id: None,
auth_profile_recorded: false,
auth_profile: None,
turn_input: "seeded app-server schedule failure",
now,
})
.await?
.expect("seeded failure occurrence should enqueue");
self.state_db
.thread_schedules()
.mark_thread_schedule_run_started(codex_state::ThreadScheduleRunStartParams {
schedule_id,
run_id: claim.run.run_id.as_str(),
lease_id: claim.run.lease_id.as_str(),
turn_id,
goal_id: None,
now,
lease_duration: std::time::Duration::from_secs(300),
})
.await?
.expect("seeded failure occurrence should start");
self.state_db
.thread_schedules()
.fail_thread_schedule_run(
Expand Down Expand Up @@ -769,13 +801,18 @@ fn message_for_test_connection(envelope: OutgoingEnvelope) -> Option<OutgoingMes
}
}

fn schedule_test_api_key() -> String {
["s", "k-test-schedule-secret"].concat()
}

async fn create_mock_responses_server_unauthorized() -> MockServer {
let server = MockServer::start().await;
let api_key = schedule_test_api_key();
Mock::given(method("POST"))
.and(path_regex(".*/responses$"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": {
"message": "Incorrect API key provided: sk-test-schedule-secret",
"message": format!("Incorrect API key provided: {api_key}"),
"type": "invalid_request_error",
"param": null,
"code": "invalid_api_key"
Expand Down Expand Up @@ -2851,7 +2888,7 @@ fn thread_schedule_run_now_records_model_errors_as_failed_runs() -> Result<()> {
"schedule run error should discard raw provider auth messages: {error}"
);
assert!(
!error.contains("sk-test-schedule-secret"),
!error.contains(schedule_test_api_key().as_str()),
"schedule run error should redact API keys: {error}"
);
assert!(failed.run.completed_at.is_some());
Expand Down
30 changes: 12 additions & 18 deletions codex-rs/app-server/src/request_processors/thread_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ use super::*;
use crate::request_processors::thread_goal_processor::api_thread_goal_from_state;
use crate::request_processors::thread_goal_processor::api_thread_goal_plan_from_state_for_thread;

mod scheduled_runs;

pub(super) const THREAD_UNLOADING_DELAY: Duration = Duration::from_secs(30 * 60);

#[derive(Clone)]
Expand Down Expand Up @@ -321,25 +323,17 @@ pub(super) async fn ensure_listener_task_running(
// Track the event before emitting any typed translations
// so thread-local state such as raw event opt-in stays
// synchronized with the conversation.
let terminal_event = matches!(
event.msg,
EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_) | EventMsg::Error(_)
);
let (raw_events_enabled, tracked_scheduled_run, turn_error) = {
let scheduled_runs::TrackedScheduledEvent {
raw_events_enabled,
terminal_event,
scheduled_run: tracked_scheduled_run,
turn_error,
} = {
let mut thread_state = thread_state.lock().await;
thread_state.track_current_turn_event(&event.id, &event.msg);
let tracked_scheduled_run = if terminal_event {
thread_state.take_scheduled_run(&event.id)
} else {
None
};
let turn_error = terminal_event
.then(|| thread_state.turn_summary.last_error.clone())
.flatten();
(
thread_state.experimental_raw_events,
tracked_scheduled_run,
turn_error,
scheduled_runs::track_scheduled_event(
&mut thread_state,
event.id.as_str(),
&event.msg,
)
};
let terminal_scheduled_run = match (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
//! Scheduled-run tracking owned by the live thread event listener.

use super::*;

pub(super) struct TrackedScheduledEvent {
pub(super) raw_events_enabled: bool,
pub(super) terminal_event: bool,
pub(super) scheduled_run: Option<crate::thread_state::ScheduledThreadScheduleRun>,
pub(super) turn_error: Option<codex_app_server_protocol::TurnError>,
}

pub(super) fn track_scheduled_event(
thread_state: &mut ThreadState,
turn_id: &str,
event: &EventMsg,
) -> TrackedScheduledEvent {
let terminal_event = matches!(event, EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_))
|| matches!(event, EventMsg::Error(error) if error.affects_turn_status());
thread_state.track_current_turn_event(turn_id, event);
let scheduled_run = terminal_event
.then(|| thread_state.take_scheduled_run(turn_id))
.flatten();
let turn_error = terminal_event
.then(|| thread_state.turn_summary.last_error.clone())
.flatten();
TrackedScheduledEvent {
raw_events_enabled: thread_state.experimental_raw_events,
terminal_event,
scheduled_run,
turn_error,
}
}

#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::TurnCompleteEvent;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc;

#[tokio::test]
async fn non_affecting_error_keeps_scheduled_run_running_until_turn_complete() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let state_db = codex_state::StateRuntime::init(
temp_dir.path().to_path_buf(),
"fallback-provider".to_string(),
)
.await
.expect("state db should initialize");
let thread_id = ThreadId::new();
let now = Utc::now();
let mut builder = ThreadMetadataBuilder::new(
thread_id,
temp_dir.path().join("thread.jsonl"),
now,
SessionSource::Cli,
);
builder.cwd = temp_dir.path().join("workspace");
state_db
.upsert_thread(&builder.build("fallback-provider"))
.await
.expect("thread metadata should persist");
let schedule = state_db
.thread_schedules()
.create_thread_schedule(codex_state::ThreadScheduleCreateParams {
thread_id,
prompt: "finish after a non-terminal error".to_string(),
prompt_source: codex_state::ThreadSchedulePromptSource::Inline,
schedule: codex_state::ThreadScheduleSpec::Interval(
codex_state::ThreadScheduleInterval {
amount: 5,
unit: codex_state::ThreadScheduleIntervalUnit::Minutes,
},
),
timezone: "UTC".to_string(),
status: codex_state::ThreadScheduleStatus::Active,
next_run_at: Some(now),
expires_at: None,
})
.await
.expect("schedule should create");
let claim = state_db
.thread_schedules()
.claim_due_thread_schedule(now, "lease-live", Duration::from_secs(300))
.await
.expect("schedule claim should succeed")
.expect("schedule should be due");
let turn_id = claim
.run
.turn_id
.as_deref()
.expect("claimed occurrence should reserve a turn")
.to_string();
state_db
.thread_schedules()
.enqueue_thread_schedule_run(codex_state::ThreadScheduleRunEnqueueParams {
schedule_id: schedule.schedule_id.as_str(),
run_id: claim.run.run_id.as_str(),
lease_id: claim.run.lease_id.as_str(),
goal_id: None,
auth_profile_recorded: true,
auth_profile: None,
turn_input: "scheduled input",
now,
})
.await
.expect("occurrence should enqueue")
.expect("owned occurrence should enqueue");
let running = state_db
.thread_schedules()
.mark_thread_schedule_run_started(codex_state::ThreadScheduleRunStartParams {
schedule_id: schedule.schedule_id.as_str(),
run_id: claim.run.run_id.as_str(),
lease_id: claim.run.lease_id.as_str(),
turn_id: turn_id.as_str(),
goal_id: None,
now,
lease_duration: Duration::from_secs(300),
})
.await
.expect("occurrence should start")
.expect("owned occurrence should materialize one run");

let mut thread_state = ThreadState::default();
thread_state.track_scheduled_run(
turn_id.clone(),
crate::thread_state::ScheduledThreadScheduleRun {
schedule_id: schedule.schedule_id.clone(),
run_id: running.run_id.clone(),
lease_id: running.lease_id.clone(),
goal_id: None,
state_db: state_db.clone(),
},
);
let non_terminal_error = EventMsg::Error(ErrorEvent {
message: "rollback request failed".to_string(),
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
});
let tracked =
track_scheduled_event(&mut thread_state, turn_id.as_str(), &non_terminal_error);
assert!(!tracked.terminal_event);
assert!(tracked.scheduled_run.is_none());
assert!(thread_state.has_scheduled_run(turn_id.as_str()));
assert_eq!(
codex_state::ThreadScheduleRunStatus::Running,
state_db
.thread_schedules()
.get_thread_schedule_run(running.run_id.as_str())
.await
.expect("running row should load")
.expect("running row should remain")
.status
);

let complete = EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_id.clone(),
last_agent_message: Some("finished after rollback warning".to_string()),
completed_at: Some(now.timestamp() + 1),
duration_ms: Some(1_000),
time_to_first_token_ms: Some(100),
});
let tracked = track_scheduled_event(&mut thread_state, turn_id.as_str(), &complete);
assert!(tracked.terminal_event);
assert!(!thread_state.has_scheduled_run(turn_id.as_str()));
let scheduled_run = tracked
.scheduled_run
.expect("terminal event should take the tracked run once");
let (outgoing_tx, _outgoing_rx) = mpsc::channel(/*buffer*/ 8);
let outgoing = Arc::new(OutgoingMessageSender::new(
outgoing_tx,
codex_analytics::AnalyticsEventsClient::disabled(),
));
super::super::thread_schedule_runtime::finish_scheduled_run_after_turn(
thread_id,
scheduled_run,
&complete,
tracked.turn_error,
&outgoing,
)
.await;
assert_eq!(
codex_state::ThreadScheduleRunStatus::Completed,
state_db
.thread_schedules()
.get_thread_schedule_run(running.run_id.as_str())
.await
.expect("completed row should load")
.expect("completed row should remain")
.status
);
assert!(
super::super::thread_schedule_runtime::recover_scheduled_run_for_terminal_turn(
&state_db,
thread_id,
turn_id.as_str(),
)
.await
.expect("completed run recovery should not fail")
.is_none(),
"a completed run must not be finalized twice"
);
}
}
Loading
Loading