mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
High-fanout agent-job runs were opening many StateRuntime instances against the same state_5.sqlite during subagent and session startup. That amplified SQLite writer contention and caused spawn_agents_on_csv to fail immediately with database-locked errors before workers could run. Fix this by caching rollout StateRuntime handles per (sqlite_home, provider) so subagents reuse one SQLite runtime instead of opening a new pool. Also retry transient SQLite lock errors around agent-job DB operations, replace per-thread reclaim lookups with one running-items query, and map cancelled background tool tasks to normal tool failures instead of panicking the turn drain path. Add rollout tests covering cache reuse and concurrent initialization.
267 lines
8.9 KiB
Rust
267 lines
8.9 KiB
Rust
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
|
|
use tokio::sync::RwLock;
|
|
use tokio::task::JoinError;
|
|
use tokio_util::either::Either;
|
|
use tokio_util::sync::CancellationToken;
|
|
use tokio_util::task::AbortOnDropHandle;
|
|
use tracing::Instrument;
|
|
use tracing::instrument;
|
|
use tracing::trace_span;
|
|
|
|
use crate::codex::Session;
|
|
use crate::codex::TurnContext;
|
|
use crate::function_tool::FunctionCallError;
|
|
use crate::tools::context::AbortedToolOutput;
|
|
use crate::tools::context::SharedTurnDiffTracker;
|
|
use crate::tools::context::ToolPayload;
|
|
use crate::tools::registry::AnyToolResult;
|
|
use crate::tools::router::ToolCall;
|
|
use crate::tools::router::ToolCallSource;
|
|
use crate::tools::router::ToolRouter;
|
|
use codex_protocol::error::CodexErr;
|
|
use codex_protocol::models::ResponseInputItem;
|
|
use codex_tools::ToolSpec;
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct ToolCallRuntime {
|
|
router: Arc<ToolRouter>,
|
|
session: Arc<Session>,
|
|
turn_context: Arc<TurnContext>,
|
|
tracker: SharedTurnDiffTracker,
|
|
parallel_execution: Arc<RwLock<()>>,
|
|
}
|
|
|
|
impl ToolCallRuntime {
|
|
pub(crate) fn new(
|
|
router: Arc<ToolRouter>,
|
|
session: Arc<Session>,
|
|
turn_context: Arc<TurnContext>,
|
|
tracker: SharedTurnDiffTracker,
|
|
) -> Self {
|
|
Self {
|
|
router,
|
|
session,
|
|
turn_context,
|
|
tracker,
|
|
parallel_execution: Arc::new(RwLock::new(())),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn find_spec(&self, tool_name: &str) -> Option<ToolSpec> {
|
|
self.router.find_spec(tool_name)
|
|
}
|
|
|
|
#[instrument(level = "trace", skip_all)]
|
|
pub(crate) fn handle_tool_call(
|
|
self,
|
|
call: ToolCall,
|
|
cancellation_token: CancellationToken,
|
|
) -> impl std::future::Future<Output = Result<ResponseInputItem, CodexErr>> {
|
|
let error_call = call.clone();
|
|
let future =
|
|
self.handle_tool_call_with_source(call, ToolCallSource::Direct, cancellation_token);
|
|
async move {
|
|
match future.await {
|
|
Ok(response) => Ok(response.into_response()),
|
|
Err(FunctionCallError::Fatal(message)) => Err(CodexErr::Fatal(message)),
|
|
Err(other) => Ok(Self::failure_response(error_call, other)),
|
|
}
|
|
}
|
|
.in_current_span()
|
|
}
|
|
|
|
#[instrument(level = "trace", skip_all)]
|
|
pub(crate) fn handle_tool_call_with_source(
|
|
self,
|
|
call: ToolCall,
|
|
source: ToolCallSource,
|
|
cancellation_token: CancellationToken,
|
|
) -> impl std::future::Future<Output = Result<AnyToolResult, FunctionCallError>> {
|
|
let supports_parallel = self.router.tool_supports_parallel(&call.tool_name);
|
|
let router = Arc::clone(&self.router);
|
|
let session = Arc::clone(&self.session);
|
|
let turn = Arc::clone(&self.turn_context);
|
|
let tracker = Arc::clone(&self.tracker);
|
|
let lock = Arc::clone(&self.parallel_execution);
|
|
let started = Instant::now();
|
|
let join_error_call = call.clone();
|
|
|
|
let dispatch_span = trace_span!(
|
|
"dispatch_tool_call_with_code_mode_result",
|
|
otel.name = call.tool_name.as_str(),
|
|
tool_name = call.tool_name.as_str(),
|
|
call_id = call.call_id.as_str(),
|
|
aborted = false,
|
|
);
|
|
|
|
let handle: AbortOnDropHandle<Result<AnyToolResult, FunctionCallError>> =
|
|
AbortOnDropHandle::new(tokio::spawn(async move {
|
|
tokio::select! {
|
|
_ = cancellation_token.cancelled() => {
|
|
let secs = started.elapsed().as_secs_f32().max(0.1);
|
|
dispatch_span.record("aborted", true);
|
|
Ok(Self::aborted_response(&call, secs))
|
|
},
|
|
res = async {
|
|
let _guard = if supports_parallel {
|
|
Either::Left(lock.read().await)
|
|
} else {
|
|
Either::Right(lock.write().await)
|
|
};
|
|
|
|
router
|
|
.dispatch_tool_call_with_code_mode_result(
|
|
session,
|
|
turn,
|
|
tracker,
|
|
call.clone(),
|
|
source,
|
|
)
|
|
.instrument(dispatch_span.clone())
|
|
.await
|
|
} => res,
|
|
}
|
|
}));
|
|
|
|
async move {
|
|
handle.await.map_err(|err| {
|
|
tool_task_join_error_to_function_call_error(
|
|
err,
|
|
&join_error_call,
|
|
started.elapsed(),
|
|
)
|
|
})?
|
|
}
|
|
.in_current_span()
|
|
}
|
|
}
|
|
|
|
fn tool_task_join_error_to_function_call_error(
|
|
err: JoinError,
|
|
call: &ToolCall,
|
|
elapsed: std::time::Duration,
|
|
) -> FunctionCallError {
|
|
if err.is_cancelled() {
|
|
let secs = elapsed.as_secs_f32().max(0.1);
|
|
tracing::warn!(
|
|
tool_name = call.tool_name,
|
|
call_id = call.call_id,
|
|
elapsed_seconds = secs,
|
|
"tool task was cancelled before delivering a response"
|
|
);
|
|
return FunctionCallError::RespondToModel(format!(
|
|
"tool execution interrupted after {secs:.1}s"
|
|
));
|
|
}
|
|
|
|
FunctionCallError::Fatal(format!("tool task failed to receive: {err:?}"))
|
|
}
|
|
|
|
impl ToolCallRuntime {
|
|
fn failure_response(call: ToolCall, err: FunctionCallError) -> ResponseInputItem {
|
|
let message = err.to_string();
|
|
match call.payload {
|
|
ToolPayload::ToolSearch { .. } => ResponseInputItem::ToolSearchOutput {
|
|
call_id: call.call_id,
|
|
status: "completed".to_string(),
|
|
execution: "client".to_string(),
|
|
tools: Vec::new(),
|
|
},
|
|
ToolPayload::Custom { .. } => ResponseInputItem::CustomToolCallOutput {
|
|
call_id: call.call_id,
|
|
name: None,
|
|
output: codex_protocol::models::FunctionCallOutputPayload {
|
|
body: codex_protocol::models::FunctionCallOutputBody::Text(message),
|
|
success: Some(false),
|
|
},
|
|
},
|
|
_ => ResponseInputItem::FunctionCallOutput {
|
|
call_id: call.call_id,
|
|
output: codex_protocol::models::FunctionCallOutputPayload {
|
|
body: codex_protocol::models::FunctionCallOutputBody::Text(message),
|
|
success: Some(false),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
fn aborted_response(call: &ToolCall, secs: f32) -> AnyToolResult {
|
|
AnyToolResult {
|
|
call_id: call.call_id.clone(),
|
|
payload: call.payload.clone(),
|
|
result: Box::new(AbortedToolOutput {
|
|
message: Self::abort_message(call, secs),
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn abort_message(call: &ToolCall, secs: f32) -> String {
|
|
match call.tool_name.as_str() {
|
|
"shell" | "container.exec" | "local_shell" | "shell_command" | "unified_exec" => {
|
|
format!("Wall time: {secs:.1} seconds\naborted by user")
|
|
}
|
|
_ => format!("aborted by user after {secs:.1}s"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::tools::context::ToolPayload;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
|
|
fn test_tool_call() -> ToolCall {
|
|
ToolCall {
|
|
tool_name: "spawn_agents_on_csv".to_string(),
|
|
call_id: "call-1".to_string(),
|
|
tool_namespace: None,
|
|
payload: ToolPayload::Function {
|
|
arguments: json!({"csv_path":"in.csv","instruction":"test"}).to_string(),
|
|
},
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancelled_tool_task_join_error_becomes_model_response() {
|
|
let handle = tokio::spawn(async {
|
|
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
|
});
|
|
handle.abort();
|
|
let err = handle.await.expect_err("task should be cancelled");
|
|
|
|
let mapped = tool_task_join_error_to_function_call_error(
|
|
err,
|
|
&test_tool_call(),
|
|
std::time::Duration::from_millis(200),
|
|
);
|
|
|
|
assert_eq!(
|
|
mapped,
|
|
FunctionCallError::RespondToModel("tool execution interrupted after 0.2s".to_string())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn panicked_tool_task_join_error_stays_fatal() {
|
|
let handle = tokio::spawn(async {
|
|
panic!("boom");
|
|
});
|
|
let err = handle.await.expect_err("task should panic");
|
|
|
|
let mapped = tool_task_join_error_to_function_call_error(
|
|
err,
|
|
&test_tool_call(),
|
|
std::time::Duration::from_millis(200),
|
|
);
|
|
|
|
let FunctionCallError::Fatal(message) = mapped else {
|
|
panic!("panic join errors should remain fatal");
|
|
};
|
|
assert!(message.contains("tool task failed to receive"));
|
|
}
|
|
}
|