mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why SQLite state was still being opened from consumer paths, including lazy `OnceCell`-backed thread-store call sites. That let one process construct multiple state DB connections for the same Codex home, which makes SQLite lock contention and `database is locked` failures much easier to hit. State DB lifetime should be chosen by main-like entrypoints and tests, then passed through explicitly. Consumers should use the supplied `Option<StateDbHandle>` or `StateDbHandle` and keep their existing filesystem fallback or error behavior when no handle is available. The startup path also needs to keep the rollout crate in charge of SQLite state initialization. Opening `codex_state::StateRuntime` directly bypasses rollout metadata backfill, so entrypoints should initialize through `codex_rollout::state_db` and receive a handle only after required rollout backfills have completed. ## What Changed - Initialize the state DB in main-like entrypoints for CLI, TUI, app-server, exec, MCP server, and the thread-manager sample. - Pass `Option<StateDbHandle>` through `ThreadManager`, `LocalThreadStore`, app-server processors, TUI app wiring, rollout listing/recording, personality migration, shell snapshot cleanup, session-name lookup, and memory/device-key consumers. - Remove the lazy local state DB wrapper from the thread store so non-test consumers use only the supplied handle or their existing fallback path. - Make `codex_rollout::state_db::init` the local state startup path: it opens/migrates SQLite, runs rollout metadata backfill when needed, waits for concurrent backfill workers up to a bounded timeout, verifies completion, and then returns the initialized handle. - Keep optional/non-owning SQLite helpers, such as remote TUI local reads, as open-only paths that do not run startup backfill. - Switch app-server startup from direct `codex_state::StateRuntime::init` to the rollout state initializer so app-server cannot skip rollout backfill. - Collapse split rollout lookup/list APIs so callers use the normal methods with an optional state handle instead of `_with_state_db` variants. - Restore `getConversationSummary(ThreadId)` to delegate through `ThreadStore::read_thread` instead of a LocalThreadStore-specific rollout path special case. - Keep DB-backed rollout path lookup keyed on the DB row and file existence, without imposing the filesystem filename convention on existing DB rows. - Verify readable DB-backed rollout paths against `session_meta.id` before returning them, so a stale SQLite row that points at another thread's JSONL falls back to filesystem search and read-repairs the DB row. - Keep `debug prompt-input` filesystem-only so a one-off debug command does not initialize or backfill SQLite state just to print prompt input. - Keep goal-session test Codex homes alive only in the goal-specific helper, rather than leaking tempdirs from the shared session test helper. - Update tests and call sites to pass explicit state handles where DB behavior is expected and explicit `None` where filesystem-only behavior is intended. ## Validation - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo check -p codex-rollout -p codex-thread-store -p codex-app-server -p codex-core -p codex-tui -p codex-exec -p codex-cli --tests` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout state_db_` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout find_thread_path` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout find_thread_path -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout try_init_ -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo clippy -p codex-rollout --lib -- -D warnings` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-thread-store read_thread_falls_back_when_sqlite_path_points_to_another_thread -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-thread-store` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core shell_snapshot` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all personality_migration` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all rollout_list_find` - `RUST_MIN_STACK=8388608 CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all rollout_list_find::find_prefers_sqlite_path_by_id -- --nocapture` - `RUST_MIN_STACK=8388608 CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all rollout_list_find -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core interrupt_accounts_active_goal_before_pausing` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-app-server get_auth_status -- --test-threads=1` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-app-server --lib` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo check -p codex-rollout -p codex-app-server --tests` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-rollout -p codex-thread-store -p codex-core -p codex-app-server -p codex-tui -p codex-exec -p codex-cli` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-rollout -p codex-app-server` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-rollout` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-core` - `just argument-comment-lint -p codex-core` - `just argument-comment-lint -p codex-rollout` Focused coverage added in `codex-rollout`: - `recorder::tests::state_db_init_backfills_before_returning` verifies the rollout metadata row exists before startup init returns. - `state_db::tests::try_init_waits_for_concurrent_startup_backfill` verifies startup waits for another worker to finish backfill instead of disabling the handle for the process. - `state_db::tests::try_init_times_out_waiting_for_stuck_startup_backfill` verifies startup does not hang indefinitely on a stuck backfill lease. - `tests::find_thread_path_accepts_existing_state_db_path_without_canonical_filename` verifies DB-backed lookup accepts valid existing rollout paths even when the filename does not include the thread UUID. - `tests::find_thread_path_falls_back_when_db_path_points_to_another_thread` verifies DB-backed lookup ignores a stale row whose existing path belongs to another thread and read-repairs the row after filesystem fallback. Focused coverage updated in `codex-core`: - `rollout_list_find::find_prefers_sqlite_path_by_id` now uses a DB-preferred rollout file with matching `session_meta.id`, so it still verifies that valid SQLite paths win without depending on stale/empty rollout contents. `cargo test -p codex-app-server thread_list_respects_search_term_filter -- --test-threads=1 --nocapture` was attempted locally but timed out waiting for the app-server test harness `initialize` response before reaching the changed thread-list code path. `bazel test //codex-rs/thread-store:thread-store-unit-tests --test_output=errors` was attempted locally after the thread-store fix, but this container failed before target analysis while fetching `v8+` through BuildBuddy/direct GitHub. The equivalent local crate coverage, including `cargo test -p codex-thread-store`, passes. A plain local `cargo check -p codex-rollout -p codex-app-server --tests` also requires system `libcap.pc` for `codex-linux-sandbox`; the follow-up app-server check above used `CODEX_SKIP_VENDORED_BWRAP=1` in this container.
1134 lines
41 KiB
Rust
1134 lines
41 KiB
Rust
use super::*;
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct TurnRequestProcessor {
|
|
auth_manager: Arc<AuthManager>,
|
|
thread_manager: Arc<ThreadManager>,
|
|
outgoing: Arc<OutgoingMessageSender>,
|
|
analytics_events_client: AnalyticsEventsClient,
|
|
arg0_paths: Arg0DispatchPaths,
|
|
config: Arc<Config>,
|
|
config_manager: ConfigManager,
|
|
pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>,
|
|
thread_state_manager: ThreadStateManager,
|
|
thread_watch_manager: ThreadWatchManager,
|
|
thread_list_state_permit: Arc<Semaphore>,
|
|
state_db: Option<StateDbHandle>,
|
|
}
|
|
|
|
impl TurnRequestProcessor {
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn new(
|
|
auth_manager: Arc<AuthManager>,
|
|
thread_manager: Arc<ThreadManager>,
|
|
outgoing: Arc<OutgoingMessageSender>,
|
|
analytics_events_client: AnalyticsEventsClient,
|
|
arg0_paths: Arg0DispatchPaths,
|
|
config: Arc<Config>,
|
|
config_manager: ConfigManager,
|
|
pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>,
|
|
thread_state_manager: ThreadStateManager,
|
|
thread_watch_manager: ThreadWatchManager,
|
|
thread_list_state_permit: Arc<Semaphore>,
|
|
state_db: Option<StateDbHandle>,
|
|
) -> Self {
|
|
Self {
|
|
auth_manager,
|
|
thread_manager,
|
|
outgoing,
|
|
analytics_events_client,
|
|
arg0_paths,
|
|
config,
|
|
config_manager,
|
|
pending_thread_unloads,
|
|
thread_state_manager,
|
|
thread_watch_manager,
|
|
thread_list_state_permit,
|
|
state_db,
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn turn_start(
|
|
&self,
|
|
request_id: ConnectionRequestId,
|
|
params: TurnStartParams,
|
|
app_server_client_name: Option<String>,
|
|
app_server_client_version: Option<String>,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
self.turn_start_inner(
|
|
request_id,
|
|
params,
|
|
app_server_client_name,
|
|
app_server_client_version,
|
|
)
|
|
.await
|
|
.map(|response| Some(response.into()))
|
|
}
|
|
|
|
pub(crate) async fn thread_inject_items(
|
|
&self,
|
|
params: ThreadInjectItemsParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
self.thread_inject_items_response_inner(params)
|
|
.await
|
|
.map(|response| Some(response.into()))
|
|
}
|
|
|
|
pub(crate) async fn turn_steer(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: TurnSteerParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
self.turn_steer_inner(request_id, params)
|
|
.await
|
|
.map(|response| Some(response.into()))
|
|
}
|
|
|
|
pub(crate) async fn turn_interrupt(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: TurnInterruptParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
self.turn_interrupt_inner(request_id, params)
|
|
.await
|
|
.map(|response| response.map(Into::into))
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_start(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: ThreadRealtimeStartParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
self.thread_realtime_start_inner(request_id, params)
|
|
.await
|
|
.map(|response| response.map(Into::into))
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_append_audio(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: ThreadRealtimeAppendAudioParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
self.thread_realtime_append_audio_inner(request_id, params)
|
|
.await
|
|
.map(|response| response.map(Into::into))
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_append_text(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: ThreadRealtimeAppendTextParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
self.thread_realtime_append_text_inner(request_id, params)
|
|
.await
|
|
.map(|response| response.map(Into::into))
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_stop(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: ThreadRealtimeStopParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
self.thread_realtime_stop_inner(request_id, params)
|
|
.await
|
|
.map(|response| response.map(Into::into))
|
|
}
|
|
|
|
pub(crate) async fn thread_realtime_list_voices(
|
|
&self,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
Ok(Some(
|
|
ThreadRealtimeListVoicesResponse {
|
|
voices: RealtimeVoicesList::builtin(),
|
|
}
|
|
.into(),
|
|
))
|
|
}
|
|
|
|
pub(crate) async fn review_start(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: ReviewStartParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
self.review_start_inner(request_id, params)
|
|
.await
|
|
.map(|()| None)
|
|
}
|
|
|
|
fn track_error_response(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
error: &JSONRPCErrorError,
|
|
error_type: Option<AnalyticsJsonRpcError>,
|
|
) {
|
|
self.analytics_events_client.track_error_response(
|
|
request_id.connection_id.0,
|
|
request_id.request_id.clone(),
|
|
error.clone(),
|
|
error_type,
|
|
);
|
|
}
|
|
|
|
async fn load_thread(
|
|
&self,
|
|
thread_id: &str,
|
|
) -> Result<(ThreadId, Arc<CodexThread>), JSONRPCErrorError> {
|
|
// Resolve the core conversation handle from a v2 thread id string.
|
|
let thread_id = ThreadId::from_string(thread_id).map_err(|err| JSONRPCErrorError {
|
|
code: INVALID_REQUEST_ERROR_CODE,
|
|
message: format!("invalid thread id: {err}"),
|
|
data: None,
|
|
})?;
|
|
|
|
let thread = self
|
|
.thread_manager
|
|
.get_thread(thread_id)
|
|
.await
|
|
.map_err(|_| JSONRPCErrorError {
|
|
code: INVALID_REQUEST_ERROR_CODE,
|
|
message: format!("thread not found: {thread_id}"),
|
|
data: None,
|
|
})?;
|
|
|
|
Ok((thread_id, thread))
|
|
}
|
|
fn normalize_turn_start_collaboration_mode(
|
|
&self,
|
|
mut collaboration_mode: CollaborationMode,
|
|
) -> CollaborationMode {
|
|
if collaboration_mode.settings.developer_instructions.is_none()
|
|
&& let Some(instructions) = builtin_collaboration_mode_presets()
|
|
.into_iter()
|
|
.find(|preset| preset.mode == Some(collaboration_mode.mode))
|
|
.and_then(|preset| preset.developer_instructions.flatten())
|
|
.filter(|instructions| !instructions.is_empty())
|
|
{
|
|
collaboration_mode.settings.developer_instructions = Some(instructions);
|
|
}
|
|
|
|
collaboration_mode
|
|
}
|
|
|
|
fn review_request_from_target(
|
|
target: ApiReviewTarget,
|
|
) -> Result<(ReviewRequest, String), JSONRPCErrorError> {
|
|
fn invalid_request(message: String) -> JSONRPCErrorError {
|
|
JSONRPCErrorError {
|
|
code: INVALID_REQUEST_ERROR_CODE,
|
|
message,
|
|
data: None,
|
|
}
|
|
}
|
|
|
|
let cleaned_target = match target {
|
|
ApiReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges,
|
|
ApiReviewTarget::BaseBranch { branch } => {
|
|
let branch = branch.trim().to_string();
|
|
if branch.is_empty() {
|
|
return Err(invalid_request("branch must not be empty".to_string()));
|
|
}
|
|
ApiReviewTarget::BaseBranch { branch }
|
|
}
|
|
ApiReviewTarget::Commit { sha, title } => {
|
|
let sha = sha.trim().to_string();
|
|
if sha.is_empty() {
|
|
return Err(invalid_request("sha must not be empty".to_string()));
|
|
}
|
|
let title = title
|
|
.map(|t| t.trim().to_string())
|
|
.filter(|t| !t.is_empty());
|
|
ApiReviewTarget::Commit { sha, title }
|
|
}
|
|
ApiReviewTarget::Custom { instructions } => {
|
|
let trimmed = instructions.trim().to_string();
|
|
if trimmed.is_empty() {
|
|
return Err(invalid_request(
|
|
"instructions must not be empty".to_string(),
|
|
));
|
|
}
|
|
ApiReviewTarget::Custom {
|
|
instructions: trimmed,
|
|
}
|
|
}
|
|
};
|
|
|
|
let core_target = match cleaned_target {
|
|
ApiReviewTarget::UncommittedChanges => CoreReviewTarget::UncommittedChanges,
|
|
ApiReviewTarget::BaseBranch { branch } => CoreReviewTarget::BaseBranch { branch },
|
|
ApiReviewTarget::Commit { sha, title } => CoreReviewTarget::Commit { sha, title },
|
|
ApiReviewTarget::Custom { instructions } => CoreReviewTarget::Custom { instructions },
|
|
};
|
|
|
|
let hint = codex_core::review_prompts::user_facing_hint(&core_target);
|
|
let review_request = ReviewRequest {
|
|
target: core_target,
|
|
user_facing_hint: Some(hint.clone()),
|
|
};
|
|
|
|
Ok((review_request, hint))
|
|
}
|
|
|
|
fn parse_environment_selections(
|
|
&self,
|
|
environments: Option<Vec<TurnEnvironmentParams>>,
|
|
) -> Result<Option<Vec<TurnEnvironmentSelection>>, JSONRPCErrorError> {
|
|
let environment_selections = environments.map(|environments| {
|
|
environments
|
|
.into_iter()
|
|
.map(|environment| TurnEnvironmentSelection {
|
|
environment_id: environment.environment_id,
|
|
cwd: environment.cwd,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
});
|
|
if let Some(environment_selections) = environment_selections.as_ref() {
|
|
self.thread_manager
|
|
.validate_environment_selections(environment_selections)
|
|
.map_err(|err| invalid_request(environment_selection_error_message(err)))?;
|
|
}
|
|
Ok(environment_selections)
|
|
}
|
|
|
|
async fn request_trace_context(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
) -> Option<codex_protocol::protocol::W3cTraceContext> {
|
|
self.outgoing.request_trace_context(request_id).await
|
|
}
|
|
|
|
async fn submit_core_op(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
thread: &CodexThread,
|
|
op: Op,
|
|
) -> CodexResult<String> {
|
|
thread
|
|
.submit_with_trace(op, self.request_trace_context(request_id).await)
|
|
.await
|
|
}
|
|
|
|
fn input_too_large_error(actual_chars: usize) -> JSONRPCErrorError {
|
|
JSONRPCErrorError {
|
|
code: INVALID_PARAMS_ERROR_CODE,
|
|
message: format!(
|
|
"Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters."
|
|
),
|
|
data: Some(serde_json::json!({
|
|
"input_error_code": INPUT_TOO_LARGE_ERROR_CODE,
|
|
"max_chars": MAX_USER_INPUT_TEXT_CHARS,
|
|
"actual_chars": actual_chars,
|
|
})),
|
|
}
|
|
}
|
|
|
|
fn validate_v2_input_limit(items: &[V2UserInput]) -> Result<(), JSONRPCErrorError> {
|
|
let actual_chars: usize = items.iter().map(V2UserInput::text_char_count).sum();
|
|
if actual_chars > MAX_USER_INPUT_TEXT_CHARS {
|
|
return Err(Self::input_too_large_error(actual_chars));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn turn_start_inner(
|
|
&self,
|
|
request_id: ConnectionRequestId,
|
|
params: TurnStartParams,
|
|
app_server_client_name: Option<String>,
|
|
app_server_client_version: Option<String>,
|
|
) -> Result<TurnStartResponse, JSONRPCErrorError> {
|
|
if let Err(error) = Self::validate_v2_input_limit(¶ms.input) {
|
|
self.track_error_response(
|
|
&request_id,
|
|
&error,
|
|
Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)),
|
|
);
|
|
return Err(error);
|
|
}
|
|
let (thread_id, thread) =
|
|
self.load_thread(¶ms.thread_id)
|
|
.await
|
|
.inspect_err(|error| {
|
|
self.track_error_response(&request_id, error, /*error_type*/ None);
|
|
})?;
|
|
Self::set_app_server_client_info(
|
|
thread.as_ref(),
|
|
app_server_client_name,
|
|
app_server_client_version,
|
|
)
|
|
.await
|
|
.inspect_err(|error| {
|
|
self.track_error_response(&request_id, error, /*error_type*/ None);
|
|
})?;
|
|
|
|
let collaboration_mode = params
|
|
.collaboration_mode
|
|
.map(|mode| self.normalize_turn_start_collaboration_mode(mode));
|
|
let environment_selections = self.parse_environment_selections(params.environments)?;
|
|
|
|
// Map v2 input items to core input items.
|
|
let mapped_items: Vec<CoreInputItem> = params
|
|
.input
|
|
.into_iter()
|
|
.map(V2UserInput::into_core)
|
|
.collect();
|
|
let turn_has_input = !mapped_items.is_empty();
|
|
|
|
let has_any_overrides = params.cwd.is_some()
|
|
|| params.approval_policy.is_some()
|
|
|| params.approvals_reviewer.is_some()
|
|
|| params.sandbox_policy.is_some()
|
|
|| params.permissions.is_some()
|
|
|| params.model.is_some()
|
|
|| params.service_tier.is_some()
|
|
|| params.effort.is_some()
|
|
|| params.summary.is_some()
|
|
|| collaboration_mode.is_some()
|
|
|| params.personality.is_some();
|
|
|
|
if params.sandbox_policy.is_some() && params.permissions.is_some() {
|
|
return Err(invalid_request(
|
|
"`permissions` cannot be combined with `sandboxPolicy`",
|
|
));
|
|
}
|
|
|
|
let cwd = params.cwd;
|
|
let approval_policy = params.approval_policy.map(AskForApproval::to_core);
|
|
let approvals_reviewer = params
|
|
.approvals_reviewer
|
|
.map(codex_app_server_protocol::ApprovalsReviewer::to_core);
|
|
let sandbox_policy = params.sandbox_policy.map(|p| p.to_core());
|
|
let (permission_profile, active_permission_profile) =
|
|
if let Some(permissions) = params.permissions {
|
|
let snapshot = thread.config_snapshot().await;
|
|
let mut overrides = ConfigOverrides {
|
|
cwd: cwd.clone(),
|
|
codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(),
|
|
main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(),
|
|
..Default::default()
|
|
};
|
|
apply_permission_profile_selection_to_config_overrides(
|
|
&mut overrides,
|
|
Some(permissions),
|
|
);
|
|
let config = self
|
|
.config_manager
|
|
.load_for_cwd(
|
|
/*request_overrides*/ None,
|
|
overrides,
|
|
Some(snapshot.cwd.to_path_buf()),
|
|
)
|
|
.await
|
|
.map_err(|err| config_load_error(&err))?;
|
|
// Startup config is allowed to fall back when requirements
|
|
// disallow a configured profile. An explicit turn request
|
|
// is different: reject it before accepting user input.
|
|
if let Some(warning) = config.startup_warnings.iter().find(|warning| {
|
|
warning.contains("Configured value for `permission_profile` is disallowed")
|
|
}) {
|
|
return Err(invalid_request(format!(
|
|
"invalid turn context override: {warning}"
|
|
)));
|
|
}
|
|
(
|
|
Some(config.permissions.permission_profile()),
|
|
config.permissions.active_permission_profile(),
|
|
)
|
|
} else {
|
|
(None, None)
|
|
};
|
|
let model = params.model;
|
|
let effort = params.effort.map(Some);
|
|
let summary = params.summary;
|
|
let service_tier = params.service_tier;
|
|
let personality = params.personality;
|
|
|
|
// If any overrides are provided, validate them synchronously so the
|
|
// request can fail before accepting user input. The actual update is
|
|
// still queued together with the input below to preserve submission order.
|
|
if has_any_overrides {
|
|
thread
|
|
.validate_turn_context_overrides(CodexThreadTurnContextOverrides {
|
|
cwd: cwd.clone(),
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_policy: sandbox_policy.clone(),
|
|
permission_profile: permission_profile.clone(),
|
|
active_permission_profile: active_permission_profile.clone(),
|
|
windows_sandbox_level: None,
|
|
model: model.clone(),
|
|
effort,
|
|
summary,
|
|
service_tier,
|
|
collaboration_mode: collaboration_mode.clone(),
|
|
personality,
|
|
})
|
|
.await
|
|
.map_err(|err| invalid_request(format!("invalid turn context override: {err}")))?;
|
|
}
|
|
|
|
// Start the turn by submitting the user input. Return its submission id as turn_id.
|
|
let turn_op = if has_any_overrides {
|
|
Op::UserInputWithTurnContext {
|
|
items: mapped_items,
|
|
environments: environment_selections,
|
|
final_output_json_schema: params.output_schema,
|
|
responsesapi_client_metadata: params.responsesapi_client_metadata,
|
|
cwd,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_policy,
|
|
permission_profile,
|
|
active_permission_profile,
|
|
windows_sandbox_level: None,
|
|
model,
|
|
effort,
|
|
summary,
|
|
service_tier,
|
|
collaboration_mode,
|
|
personality,
|
|
}
|
|
} else {
|
|
Op::UserInput {
|
|
items: mapped_items,
|
|
environments: environment_selections,
|
|
final_output_json_schema: params.output_schema,
|
|
responsesapi_client_metadata: params.responsesapi_client_metadata,
|
|
}
|
|
};
|
|
let turn_id = self
|
|
.submit_core_op(&request_id, thread.as_ref(), turn_op)
|
|
.await
|
|
.map_err(|err| {
|
|
let error = internal_error(format!("failed to start turn: {err}"));
|
|
self.track_error_response(&request_id, &error, /*error_type*/ None);
|
|
error
|
|
})?;
|
|
|
|
if turn_has_input {
|
|
let config_snapshot = thread.config_snapshot().await;
|
|
codex_memories_write::start_memories_startup_task(
|
|
Arc::clone(&self.thread_manager),
|
|
Arc::clone(&self.auth_manager),
|
|
thread_id,
|
|
Arc::clone(&thread),
|
|
thread.config().await,
|
|
&config_snapshot.session_source,
|
|
);
|
|
}
|
|
|
|
self.outgoing
|
|
.record_request_turn_id(&request_id, &turn_id)
|
|
.await;
|
|
let turn = Turn {
|
|
id: turn_id,
|
|
items: vec![],
|
|
error: None,
|
|
status: TurnStatus::InProgress,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
};
|
|
|
|
Ok(TurnStartResponse { turn })
|
|
}
|
|
|
|
async fn thread_inject_items_response_inner(
|
|
&self,
|
|
params: ThreadInjectItemsParams,
|
|
) -> Result<ThreadInjectItemsResponse, JSONRPCErrorError> {
|
|
let (_, thread) = self.load_thread(¶ms.thread_id).await?;
|
|
|
|
let items = params
|
|
.items
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(index, value)| {
|
|
serde_json::from_value::<ResponseItem>(value)
|
|
.map_err(|err| format!("items[{index}] is not a valid response item: {err}"))
|
|
})
|
|
.collect::<std::result::Result<Vec<_>, _>>()
|
|
.map_err(invalid_request)?;
|
|
|
|
thread
|
|
.inject_response_items(items)
|
|
.await
|
|
.map_err(|err| match err {
|
|
CodexErr::InvalidRequest(message) => invalid_request(message),
|
|
err => internal_error(format!("failed to inject response items: {err}")),
|
|
})?;
|
|
Ok(ThreadInjectItemsResponse {})
|
|
}
|
|
|
|
async fn set_app_server_client_info(
|
|
thread: &CodexThread,
|
|
app_server_client_name: Option<String>,
|
|
app_server_client_version: Option<String>,
|
|
) -> Result<(), JSONRPCErrorError> {
|
|
thread
|
|
.set_app_server_client_info(app_server_client_name, app_server_client_version)
|
|
.await
|
|
.map_err(|err| JSONRPCErrorError {
|
|
code: INTERNAL_ERROR_CODE,
|
|
message: format!("failed to set app server client info: {err}"),
|
|
data: None,
|
|
})
|
|
}
|
|
|
|
async fn turn_steer_inner(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: TurnSteerParams,
|
|
) -> Result<TurnSteerResponse, JSONRPCErrorError> {
|
|
let (_, thread) = self
|
|
.load_thread(¶ms.thread_id)
|
|
.await
|
|
.inspect_err(|error| {
|
|
self.track_error_response(request_id, error, /*error_type*/ None);
|
|
})?;
|
|
|
|
if params.expected_turn_id.is_empty() {
|
|
return Err(invalid_request("expectedTurnId must not be empty"));
|
|
}
|
|
self.outgoing
|
|
.record_request_turn_id(request_id, ¶ms.expected_turn_id)
|
|
.await;
|
|
if let Err(error) = Self::validate_v2_input_limit(¶ms.input) {
|
|
self.track_error_response(
|
|
request_id,
|
|
&error,
|
|
Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)),
|
|
);
|
|
return Err(error);
|
|
}
|
|
|
|
let mapped_items: Vec<CoreInputItem> = params
|
|
.input
|
|
.into_iter()
|
|
.map(V2UserInput::into_core)
|
|
.collect();
|
|
|
|
let turn_id = thread
|
|
.steer_input(
|
|
mapped_items,
|
|
Some(¶ms.expected_turn_id),
|
|
params.responsesapi_client_metadata,
|
|
)
|
|
.await
|
|
.map_err(|err| {
|
|
let (code, message, data, error_type) = match err {
|
|
SteerInputError::NoActiveTurn(_) => (
|
|
INVALID_REQUEST_ERROR_CODE,
|
|
"no active turn to steer".to_string(),
|
|
None,
|
|
Some(AnalyticsJsonRpcError::TurnSteer(
|
|
TurnSteerRequestError::NoActiveTurn,
|
|
)),
|
|
),
|
|
SteerInputError::ExpectedTurnMismatch { expected, actual } => (
|
|
INVALID_REQUEST_ERROR_CODE,
|
|
format!("expected active turn id `{expected}` but found `{actual}`"),
|
|
None,
|
|
Some(AnalyticsJsonRpcError::TurnSteer(
|
|
TurnSteerRequestError::ExpectedTurnMismatch,
|
|
)),
|
|
),
|
|
SteerInputError::ActiveTurnNotSteerable { turn_kind } => {
|
|
let (message, turn_steer_error) = match turn_kind {
|
|
codex_protocol::protocol::NonSteerableTurnKind::Review => (
|
|
"cannot steer a review turn".to_string(),
|
|
TurnSteerRequestError::NonSteerableReview,
|
|
),
|
|
codex_protocol::protocol::NonSteerableTurnKind::Compact => (
|
|
"cannot steer a compact turn".to_string(),
|
|
TurnSteerRequestError::NonSteerableCompact,
|
|
),
|
|
};
|
|
let error = TurnError {
|
|
message: message.clone(),
|
|
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
|
turn_kind: turn_kind.into(),
|
|
}),
|
|
additional_details: None,
|
|
};
|
|
let data = match serde_json::to_value(error) {
|
|
Ok(data) => Some(data),
|
|
Err(error) => {
|
|
tracing::error!(
|
|
?error,
|
|
"failed to serialize active-turn-not-steerable turn error"
|
|
);
|
|
None
|
|
}
|
|
};
|
|
(
|
|
INVALID_REQUEST_ERROR_CODE,
|
|
message,
|
|
data,
|
|
Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)),
|
|
)
|
|
}
|
|
SteerInputError::EmptyInput => (
|
|
INVALID_REQUEST_ERROR_CODE,
|
|
"input must not be empty".to_string(),
|
|
None,
|
|
Some(AnalyticsJsonRpcError::Input(InputError::Empty)),
|
|
),
|
|
};
|
|
let error = JSONRPCErrorError {
|
|
code,
|
|
message,
|
|
data,
|
|
};
|
|
self.track_error_response(request_id, &error, error_type);
|
|
error
|
|
})?;
|
|
Ok(TurnSteerResponse { turn_id })
|
|
}
|
|
|
|
async fn prepare_realtime_conversation_thread(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
thread_id: &str,
|
|
) -> Result<Option<(ThreadId, Arc<CodexThread>)>, JSONRPCErrorError> {
|
|
let (thread_id, thread) = self.load_thread(thread_id).await?;
|
|
|
|
match self
|
|
.ensure_conversation_listener(
|
|
thread_id,
|
|
request_id.connection_id,
|
|
/*raw_events_enabled*/ false,
|
|
)
|
|
.await
|
|
{
|
|
Ok(EnsureConversationListenerResult::Attached) => {}
|
|
Ok(EnsureConversationListenerResult::ConnectionClosed) => {
|
|
return Ok(None);
|
|
}
|
|
Err(error) => return Err(error),
|
|
}
|
|
|
|
if !thread.enabled(Feature::RealtimeConversation) {
|
|
return Err(invalid_request(format!(
|
|
"thread {thread_id} does not support realtime conversation"
|
|
)));
|
|
}
|
|
|
|
Ok(Some((thread_id, thread)))
|
|
}
|
|
|
|
async fn thread_realtime_start_inner(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: ThreadRealtimeStartParams,
|
|
) -> Result<Option<ThreadRealtimeStartResponse>, JSONRPCErrorError> {
|
|
let Some((_, thread)) = self
|
|
.prepare_realtime_conversation_thread(request_id, ¶ms.thread_id)
|
|
.await?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
self.submit_core_op(
|
|
request_id,
|
|
thread.as_ref(),
|
|
Op::RealtimeConversationStart(ConversationStartParams {
|
|
output_modality: params.output_modality,
|
|
prompt: params.prompt,
|
|
realtime_session_id: params.realtime_session_id,
|
|
transport: params.transport.map(|transport| match transport {
|
|
ThreadRealtimeStartTransport::Websocket => {
|
|
ConversationStartTransport::Websocket
|
|
}
|
|
ThreadRealtimeStartTransport::Webrtc { sdp } => {
|
|
ConversationStartTransport::Webrtc { sdp }
|
|
}
|
|
}),
|
|
voice: params.voice,
|
|
}),
|
|
)
|
|
.await
|
|
.map_err(|err| internal_error(format!("failed to start realtime conversation: {err}")))?;
|
|
Ok(Some(ThreadRealtimeStartResponse::default()))
|
|
}
|
|
|
|
async fn thread_realtime_append_audio_inner(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: ThreadRealtimeAppendAudioParams,
|
|
) -> Result<Option<ThreadRealtimeAppendAudioResponse>, JSONRPCErrorError> {
|
|
let Some((_, thread)) = self
|
|
.prepare_realtime_conversation_thread(request_id, ¶ms.thread_id)
|
|
.await?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
self.submit_core_op(
|
|
request_id,
|
|
thread.as_ref(),
|
|
Op::RealtimeConversationAudio(ConversationAudioParams {
|
|
frame: params.audio.into(),
|
|
}),
|
|
)
|
|
.await
|
|
.map_err(|err| {
|
|
internal_error(format!(
|
|
"failed to append realtime conversation audio: {err}"
|
|
))
|
|
})?;
|
|
Ok(Some(ThreadRealtimeAppendAudioResponse::default()))
|
|
}
|
|
|
|
async fn thread_realtime_append_text_inner(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: ThreadRealtimeAppendTextParams,
|
|
) -> Result<Option<ThreadRealtimeAppendTextResponse>, JSONRPCErrorError> {
|
|
let Some((_, thread)) = self
|
|
.prepare_realtime_conversation_thread(request_id, ¶ms.thread_id)
|
|
.await?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
self.submit_core_op(
|
|
request_id,
|
|
thread.as_ref(),
|
|
Op::RealtimeConversationText(ConversationTextParams { text: params.text }),
|
|
)
|
|
.await
|
|
.map_err(|err| {
|
|
internal_error(format!(
|
|
"failed to append realtime conversation text: {err}"
|
|
))
|
|
})?;
|
|
Ok(Some(ThreadRealtimeAppendTextResponse::default()))
|
|
}
|
|
|
|
async fn thread_realtime_stop_inner(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: ThreadRealtimeStopParams,
|
|
) -> Result<Option<ThreadRealtimeStopResponse>, JSONRPCErrorError> {
|
|
let Some((_, thread)) = self
|
|
.prepare_realtime_conversation_thread(request_id, ¶ms.thread_id)
|
|
.await?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
self.submit_core_op(request_id, thread.as_ref(), Op::RealtimeConversationClose)
|
|
.await
|
|
.map_err(|err| {
|
|
internal_error(format!("failed to stop realtime conversation: {err}"))
|
|
})?;
|
|
Ok(Some(ThreadRealtimeStopResponse::default()))
|
|
}
|
|
|
|
fn build_review_turn(turn_id: String, display_text: &str) -> Turn {
|
|
let items = if display_text.is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
vec![ThreadItem::UserMessage {
|
|
id: turn_id.clone(),
|
|
content: vec![V2UserInput::Text {
|
|
text: display_text.to_string(),
|
|
// Review prompt display text is synthesized; no UI element ranges to preserve.
|
|
text_elements: Vec::new(),
|
|
}],
|
|
}]
|
|
};
|
|
|
|
Turn {
|
|
id: turn_id,
|
|
items,
|
|
error: None,
|
|
status: TurnStatus::InProgress,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
}
|
|
}
|
|
|
|
async fn emit_review_started(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
turn: Turn,
|
|
review_thread_id: String,
|
|
) {
|
|
let response = ReviewStartResponse {
|
|
turn,
|
|
review_thread_id,
|
|
};
|
|
self.outgoing
|
|
.send_response(request_id.clone(), response)
|
|
.await;
|
|
}
|
|
|
|
async fn start_inline_review(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
parent_thread: Arc<CodexThread>,
|
|
review_request: ReviewRequest,
|
|
display_text: &str,
|
|
parent_thread_id: String,
|
|
) -> std::result::Result<(), JSONRPCErrorError> {
|
|
let turn_id = self
|
|
.submit_core_op(
|
|
request_id,
|
|
parent_thread.as_ref(),
|
|
Op::Review { review_request },
|
|
)
|
|
.await
|
|
.map_err(|err| internal_error(format!("failed to start review: {err}")))?;
|
|
let turn = Self::build_review_turn(turn_id, display_text);
|
|
self.emit_review_started(request_id, turn, parent_thread_id)
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
async fn start_detached_review(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
parent_thread_id: ThreadId,
|
|
parent_thread: Arc<CodexThread>,
|
|
review_request: ReviewRequest,
|
|
display_text: &str,
|
|
) -> std::result::Result<(), JSONRPCErrorError> {
|
|
let rollout_path = if let Some(path) = parent_thread.rollout_path() {
|
|
path
|
|
} else {
|
|
find_thread_path_by_id_str(
|
|
&self.config.codex_home,
|
|
&parent_thread_id.to_string(),
|
|
self.state_db.as_deref(),
|
|
)
|
|
.await
|
|
.map_err(|err| {
|
|
internal_error(format!(
|
|
"failed to locate thread id {parent_thread_id}: {err}"
|
|
))
|
|
})?
|
|
.ok_or_else(|| {
|
|
invalid_request(format!("no rollout found for thread id {parent_thread_id}"))
|
|
})?
|
|
};
|
|
|
|
let mut config = self.config.as_ref().clone();
|
|
if let Some(review_model) = &config.review_model {
|
|
config.model = Some(review_model.clone());
|
|
}
|
|
|
|
let NewThread {
|
|
thread_id,
|
|
thread: review_thread,
|
|
session_configured,
|
|
..
|
|
} = self
|
|
.thread_manager
|
|
.fork_thread(
|
|
ForkSnapshot::Interrupted,
|
|
config.clone(),
|
|
rollout_path,
|
|
/*persist_extended_history*/ false,
|
|
self.request_trace_context(request_id).await,
|
|
)
|
|
.await
|
|
.map_err(|err| {
|
|
internal_error(format!("error creating detached review thread: {err}"))
|
|
})?;
|
|
|
|
log_listener_attach_result(
|
|
self.ensure_conversation_listener(
|
|
thread_id,
|
|
request_id.connection_id,
|
|
/*raw_events_enabled*/ false,
|
|
)
|
|
.await,
|
|
thread_id,
|
|
request_id.connection_id,
|
|
"review thread",
|
|
);
|
|
|
|
let fallback_provider = self.config.model_provider_id.as_str();
|
|
if let Some(rollout_path) = review_thread.rollout_path() {
|
|
match read_summary_from_rollout(rollout_path.as_path(), fallback_provider).await {
|
|
Ok(summary) => {
|
|
let mut thread = summary_to_thread(summary, &self.config.cwd);
|
|
self.thread_watch_manager
|
|
.upsert_thread_silently(thread.clone())
|
|
.await;
|
|
thread.status = resolve_thread_status(
|
|
self.thread_watch_manager
|
|
.loaded_status_for_thread(&thread.id)
|
|
.await,
|
|
/*has_in_progress_turn*/ false,
|
|
);
|
|
let notif = thread_started_notification(thread);
|
|
self.outgoing
|
|
.send_server_notification(ServerNotification::ThreadStarted(notif))
|
|
.await;
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
"failed to load summary for review thread {}: {}",
|
|
session_configured.session_id,
|
|
err
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
tracing::warn!(
|
|
"review thread {} has no rollout path",
|
|
session_configured.session_id
|
|
);
|
|
}
|
|
|
|
let turn_id = self
|
|
.submit_core_op(
|
|
request_id,
|
|
review_thread.as_ref(),
|
|
Op::Review { review_request },
|
|
)
|
|
.await
|
|
.map_err(|err| {
|
|
internal_error(format!("failed to start detached review turn: {err}"))
|
|
})?;
|
|
|
|
let turn = Self::build_review_turn(turn_id, display_text);
|
|
let review_thread_id = thread_id.to_string();
|
|
self.emit_review_started(request_id, turn, review_thread_id)
|
|
.await;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn review_start_inner(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: ReviewStartParams,
|
|
) -> Result<(), JSONRPCErrorError> {
|
|
let ReviewStartParams {
|
|
thread_id,
|
|
target,
|
|
delivery,
|
|
} = params;
|
|
|
|
let (parent_thread_id, parent_thread) = self.load_thread(&thread_id).await?;
|
|
let (review_request, display_text) = Self::review_request_from_target(target)?;
|
|
match delivery.unwrap_or(ApiReviewDelivery::Inline).to_core() {
|
|
CoreReviewDelivery::Inline => {
|
|
self.start_inline_review(
|
|
request_id,
|
|
parent_thread,
|
|
review_request,
|
|
display_text.as_str(),
|
|
thread_id,
|
|
)
|
|
.await?;
|
|
}
|
|
CoreReviewDelivery::Detached => {
|
|
self.start_detached_review(
|
|
request_id,
|
|
parent_thread_id,
|
|
parent_thread,
|
|
review_request,
|
|
display_text.as_str(),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn turn_interrupt_inner(
|
|
&self,
|
|
request_id: &ConnectionRequestId,
|
|
params: TurnInterruptParams,
|
|
) -> Result<Option<TurnInterruptResponse>, JSONRPCErrorError> {
|
|
let TurnInterruptParams { thread_id, turn_id } = params;
|
|
let is_startup_interrupt = turn_id.is_empty();
|
|
|
|
let (thread_uuid, thread) = self.load_thread(&thread_id).await?;
|
|
|
|
// Record turn interrupts so we can reply when TurnAborted arrives. Startup
|
|
// interrupts do not have a turn and are acknowledged after submission.
|
|
if !is_startup_interrupt {
|
|
let thread_state = self.thread_state_manager.thread_state(thread_uuid).await;
|
|
let is_running = matches!(thread.agent_status().await, AgentStatus::Running);
|
|
{
|
|
let mut thread_state = thread_state.lock().await;
|
|
if let Some(active_turn) = thread_state.active_turn_snapshot() {
|
|
if active_turn.id != turn_id {
|
|
return Err(invalid_request(format!(
|
|
"expected active turn id {turn_id} but found {}",
|
|
active_turn.id
|
|
)));
|
|
}
|
|
} else if thread_state.last_terminal_turn_id.as_deref() == Some(turn_id.as_str())
|
|
|| !is_running
|
|
{
|
|
return Err(invalid_request("no active turn to interrupt"));
|
|
}
|
|
thread_state.pending_interrupts.push(request_id.clone());
|
|
}
|
|
|
|
self.outgoing
|
|
.record_request_turn_id(request_id, &turn_id)
|
|
.await;
|
|
}
|
|
|
|
// Submit the interrupt. Turn interrupts respond upon TurnAborted; startup
|
|
// interrupts respond here because startup cancellation has no turn event.
|
|
match self
|
|
.submit_core_op(request_id, thread.as_ref(), Op::Interrupt)
|
|
.await
|
|
{
|
|
Ok(_) if is_startup_interrupt => Ok(Some(TurnInterruptResponse {})),
|
|
Ok(_) => Ok(None),
|
|
Err(err) => {
|
|
if !is_startup_interrupt {
|
|
let thread_state = self.thread_state_manager.thread_state(thread_uuid).await;
|
|
let mut thread_state = thread_state.lock().await;
|
|
thread_state
|
|
.pending_interrupts
|
|
.retain(|pending_request_id| pending_request_id != request_id);
|
|
}
|
|
let interrupt_target = if is_startup_interrupt {
|
|
"startup"
|
|
} else {
|
|
"turn"
|
|
};
|
|
Err(internal_error(format!(
|
|
"failed to interrupt {interrupt_target}: {err}"
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn listener_task_context(&self) -> ListenerTaskContext {
|
|
ListenerTaskContext {
|
|
thread_manager: Arc::clone(&self.thread_manager),
|
|
thread_state_manager: self.thread_state_manager.clone(),
|
|
outgoing: Arc::clone(&self.outgoing),
|
|
pending_thread_unloads: Arc::clone(&self.pending_thread_unloads),
|
|
analytics_events_client: self.analytics_events_client.clone(),
|
|
thread_watch_manager: self.thread_watch_manager.clone(),
|
|
thread_list_state_permit: self.thread_list_state_permit.clone(),
|
|
fallback_model_provider: self.config.model_provider_id.clone(),
|
|
codex_home: self.config.codex_home.to_path_buf(),
|
|
}
|
|
}
|
|
|
|
async fn ensure_conversation_listener(
|
|
&self,
|
|
conversation_id: ThreadId,
|
|
connection_id: ConnectionId,
|
|
raw_events_enabled: bool,
|
|
) -> Result<EnsureConversationListenerResult, JSONRPCErrorError> {
|
|
super::thread_lifecycle::ensure_conversation_listener(
|
|
self.listener_task_context(),
|
|
conversation_id,
|
|
connection_id,
|
|
raw_events_enabled,
|
|
)
|
|
.await
|
|
}
|
|
}
|