feat: do not close unified exec processes across turns (#10799)

With this PR we do not close the unified exec processes (i.e. background
terminals) at the end of a turn unless:
* The user interrupt the turn
* The user decide to clean the processes through `app-server` or
`/clean`

I made sure that `codex exec` correctly kill all the processes
This commit is contained in:
jif-oai
2026-02-09 10:27:46 +00:00
committed by GitHub
Unverified
parent 4e9e6ca243
commit 6cf61725d0
16 changed files with 300 additions and 46 deletions
@@ -214,6 +214,11 @@ client_request_definitions! {
params: v2::ThreadCompactStartParams,
response: v2::ThreadCompactStartResponse,
},
#[experimental("thread/backgroundTerminals/clean")]
ThreadBackgroundTerminalsClean => "thread/backgroundTerminals/clean" {
params: v2::ThreadBackgroundTerminalsCleanParams,
response: v2::ThreadBackgroundTerminalsCleanResponse,
},
ThreadRollback => "thread/rollback" {
params: v2::ThreadRollbackParams,
response: v2::ThreadRollbackResponse,
@@ -1120,6 +1125,27 @@ mod tests {
Ok(())
}
#[test]
fn serialize_thread_background_terminals_clean() -> Result<()> {
let request = ClientRequest::ThreadBackgroundTerminalsClean {
request_id: RequestId::Integer(8),
params: v2::ThreadBackgroundTerminalsCleanParams {
thread_id: "thr_123".to_string(),
},
};
assert_eq!(
json!({
"method": "thread/backgroundTerminals/clean",
"id": 8,
"params": {
"threadId": "thr_123"
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn mock_experimental_method_is_marked_experimental() {
let request = ClientRequest::MockExperimentalMethod {
@@ -1554,6 +1554,18 @@ pub struct ThreadCompactStartParams {
#[ts(export_to = "v2/")]
pub struct ThreadCompactStartResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadBackgroundTerminalsCleanParams {
pub thread_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadBackgroundTerminalsCleanResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+12
View File
@@ -93,6 +93,7 @@ Example (from OpenAI's official VSCode extension):
- `thread/name/set` — set or update a threads user-facing name; returns `{}` on success. Thread names are not required to be unique; name lookups resolve to the most recently updated thread.
- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success.
- `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications.
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
- `thread/rollback` — drop the last N turns from the agents in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
- `turn/steer` — add user input to an already in-flight turn without starting a new turn; returns the active `turnId` that accepted the input.
@@ -364,6 +365,17 @@ You can cancel a running Turn with `turn/interrupt`.
The server requests cancellations for running subprocesses, then emits a `turn/completed` event with `status: "interrupted"`. Rely on the `turn/completed` to know when Codex-side cleanup is done.
### Example: Clean background terminals
Use `thread/backgroundTerminals/clean` to terminate all running background terminals associated with a thread. This method is experimental and requires `capabilities.experimentalApi = true`.
```json
{ "method": "thread/backgroundTerminals/clean", "id": 35, "params": {
"threadId": "thr_123"
} }
{ "id": 35, "result": {} }
```
### Example: Steer an active turn
Use `turn/steer` to append additional user input to the currently active turn. This does not emit
@@ -111,6 +111,8 @@ use codex_app_server_protocol::SkillsRemoteWriteResponse;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadArchiveResponse;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadCompactStartResponse;
use codex_app_server_protocol::ThreadForkParams;
@@ -525,6 +527,13 @@ impl CodexMessageProcessor {
self.thread_compact_start(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ThreadBackgroundTerminalsClean { request_id, params } => {
self.thread_background_terminals_clean(
to_connection_request_id(request_id),
params,
)
.await;
}
ClientRequest::ThreadRollback { request_id, params } => {
self.thread_rollback(to_connection_request_id(request_id), params)
.await;
@@ -2309,6 +2318,37 @@ impl CodexMessageProcessor {
}
}
async fn thread_background_terminals_clean(
&self,
request_id: ConnectionRequestId,
params: ThreadBackgroundTerminalsCleanParams,
) {
let ThreadBackgroundTerminalsCleanParams { thread_id } = params;
let (_, thread) = match self.load_thread(&thread_id).await {
Ok(v) => v,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
match thread.submit(Op::CleanBackgroundTerminals).await {
Ok(_) => {
self.outgoing
.send_response(request_id, ThreadBackgroundTerminalsCleanResponse {})
.await;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to clean background terminals: {err}"),
)
.await;
}
}
}
async fn thread_list(&self, request_id: ConnectionRequestId, params: ThreadListParams) {
let ThreadListParams {
cursor,
+7
View File
@@ -2700,6 +2700,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
Op::Interrupt => {
handlers::interrupt(&sess).await;
}
Op::CleanBackgroundTerminals => {
handlers::clean_background_terminals(&sess).await;
}
Op::OverrideTurnContext {
cwd,
approval_policy,
@@ -2890,6 +2893,10 @@ mod handlers {
sess.interrupt_task().await;
}
pub async fn clean_background_terminals(sess: &Arc<Session>) {
sess.close_unified_exec_processes().await;
}
pub async fn override_turn_context(
sess: &Session,
sub_id: String,
+8 -9
View File
@@ -46,7 +46,7 @@ pub(crate) use user_shell::UserShellCommandTask;
pub(crate) use user_shell::execute_user_shell_command;
const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100;
const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. If any tools/commands were aborted, they may have partially executed; verify current state before retrying.";
const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. Any running unified exec processes were terminated. If any tools/commands were aborted, they may have partially executed; verify current state before retrying.";
/// Thin wrapper that exposes the parts of [`Session`] task runners need.
#[derive(Clone)]
@@ -181,7 +181,9 @@ impl Session {
for task in self.take_all_running_tasks().await {
self.handle_task_abort(task, reason.clone()).await;
}
self.close_unified_exec_processes().await;
if reason == TurnAbortReason::Interrupted {
self.close_unified_exec_processes().await;
}
}
pub async fn on_task_finished(
@@ -191,15 +193,15 @@ impl Session {
) {
let mut active = self.active_turn.lock().await;
let mut pending_input = Vec::<ResponseInputItem>::new();
let mut should_close_processes = false;
let mut should_clear_active_turn = false;
if let Some(at) = active.as_mut()
&& at.remove_task(&turn_context.sub_id)
{
let mut ts = at.turn_state.lock().await;
pending_input = ts.take_pending_input();
should_close_processes = true;
should_clear_active_turn = true;
}
if should_close_processes {
if should_clear_active_turn {
*active = None;
}
drop(active);
@@ -211,9 +213,6 @@ impl Session {
self.record_conversation_items(turn_context.as_ref(), &pending_response_items)
.await;
}
if should_close_processes {
self.close_unified_exec_processes().await;
}
let event = EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message });
self.send_event(turn_context.as_ref(), event).await;
}
@@ -237,7 +236,7 @@ impl Session {
}
}
async fn close_unified_exec_processes(&self) {
pub(crate) async fn close_unified_exec_processes(&self) {
self.services
.unified_exec_manager
.terminate_all_processes()
+87 -23
View File
@@ -14,6 +14,7 @@ use codex_core::protocol::SandboxPolicy;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::user_input::UserInput;
use core_test_support::assert_regex_match;
use core_test_support::process::process_is_alive;
use core_test_support::process::wait_for_pid_file;
use core_test_support::process::wait_for_process_exit;
use core_test_support::responses::ev_assistant_message;
@@ -1873,7 +1874,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> {
async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
skip_if_windows!(Ok(()));
@@ -1921,7 +1922,7 @@ async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> {
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "close unified exec processes on turn end".into(),
text: "keep unified exec process after turn end".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
@@ -1942,7 +1943,7 @@ async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> {
})
.await;
let begin_process_id = begin_event
let _begin_process_id = begin_event
.process_id
.clone()
.expect("expected process_id for long-running unified exec process");
@@ -1953,28 +1954,91 @@ async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> {
"expected numeric pid, got {pid:?}"
);
let mut end_event = None;
let mut task_complete = false;
loop {
let msg = wait_for_event(&codex, |_| true).await;
match msg {
EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => end_event = Some(ev),
EventMsg::TurnComplete(_) => task_complete = true,
_ => {}
}
if task_complete && end_event.is_some() {
break;
}
}
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let end_event = end_event.expect("expected ExecCommandEnd event for unified exec session");
assert_eq!(end_event.call_id, call_id);
let end_process_id = end_event
.process_id
.clone()
.expect("expected process_id in unified exec end event");
assert_eq!(end_process_id, begin_process_id);
assert!(
process_is_alive(&pid)?,
"expected unified exec process to remain alive after turn completion"
);
codex.submit(Op::Shutdown).await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await;
wait_for_process_exit(&pid).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_interrupt_terminates_long_running_session() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
skip_if_windows!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.use_experimental_unified_exec_tool = true;
config.features.enable(Feature::UnifiedExec);
});
let TestCodex {
codex,
cwd,
session_configured,
..
} = builder.build(&server).await?;
let temp_dir = tempfile::tempdir()?;
let pid_path = temp_dir.path().join("uexec_pid_interrupt");
let pid_path_str = pid_path.to_string_lossy();
let call_id = "uexec-long-running-interrupt";
let command = format!("printf '%s' $$ > '{pid_path_str}' && exec sleep 3000");
let args = json!({
"cmd": command,
"yield_time_ms": 30000,
});
let responses = vec![sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
])];
mount_sse_sequence(&server, responses).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "interrupt long-running unified exec".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
collaboration_mode: None,
personality: None,
})
.await?;
let _begin_event = wait_for_event_match(&codex, |msg| match msg {
EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => Some(ev.clone()),
_ => None,
})
.await;
let pid = wait_for_pid_file(&pid_path).await?;
assert!(
pid.chars().all(|ch| ch.is_ascii_digit()),
"expected numeric pid, got {pid:?}"
);
codex.submit(Op::Interrupt).await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnAborted(_))).await;
wait_for_process_exit(&pid).await?;
Ok(())
+8 -3
View File
@@ -530,6 +530,14 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
thread,
event,
} = envelope;
if matches!(event.msg, EventMsg::Error(_)) {
error_seen = true;
}
if shutdown_requested
&& !matches!(&event.msg, EventMsg::ShutdownComplete | EventMsg::Error(_))
{
continue;
}
if let EventMsg::ElicitationRequest(ev) = &event.msg {
// Automatically cancel elicitation requests in exec mode.
thread
@@ -554,9 +562,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
shutdown_requested = true;
}
}
if matches!(event.msg, EventMsg::Error(_)) {
error_seen = true;
}
if thread_id != primary_thread_id && matches!(&event.msg, EventMsg::TurnComplete(_)) {
continue;
}
+3
View File
@@ -91,6 +91,9 @@ pub enum Op {
/// This server sends [`EventMsg::TurnAborted`] in response.
Interrupt,
/// Terminate all running background terminal processes for this thread.
CleanBackgroundTerminals,
/// Legacy user input.
///
/// Prefer [`Op::UserTurn`] so the caller provides full turn context
@@ -5,10 +5,9 @@ expression: "format!(\"{buf:?}\")"
Buffer {
area: Rect { x: 0, y: 0, width: 50, height: 1 },
content: [
" 123 background terminals running · /ps to view ",
" 123 background terminals running · /ps to view ·",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 48, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -5,10 +5,9 @@ expression: "format!(\"{buf:?}\")"
Buffer {
area: Rect { x: 0, y: 0, width: 50, height: 1 },
content: [
" 1 background terminal running · /ps to view ",
" 1 background terminal running · /ps to view · /c",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 45, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -37,7 +37,9 @@ impl UnifiedExecFooter {
let count = self.processes.len();
let plural = if count == 1 { "" } else { "s" };
let message = format!(" {count} background terminal{plural} running · /ps to view");
let message = format!(
" {count} background terminal{plural} running · /ps to view · /clean to close"
);
let (truncated, _, _) = take_prefix_by_width(&message, width as usize);
vec![Line::from(truncated.dim())]
}
+18 -3
View File
@@ -1354,7 +1354,6 @@ impl ChatWidget {
self.suppressed_exec_calls.clear();
self.last_unified_wait = None;
self.unified_exec_wait_streak = None;
self.clear_unified_exec_processes();
self.request_redraw();
if !from_replay && self.queued_user_messages.is_empty() {
@@ -1575,7 +1574,6 @@ impl ChatWidget {
self.suppressed_exec_calls.clear();
self.last_unified_wait = None;
self.unified_exec_wait_streak = None;
self.clear_unified_exec_processes();
self.adaptive_chunking.reset();
self.stream_controller = None;
self.plan_stream_controller = None;
@@ -1690,6 +1688,9 @@ impl ChatWidget {
fn on_interrupted_turn(&mut self, reason: TurnAbortReason) {
// Finalize, log a gentle prompt, and clear running state.
self.finalize_turn();
if reason == TurnAbortReason::Interrupted {
self.clear_unified_exec_processes();
}
if reason != TurnAbortReason::ReviewEnded {
self.add_to_history(history_cell::new_error_event(
@@ -1819,9 +1820,12 @@ impl ChatWidget {
fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) {
self.flush_answer_stream_with_separator();
if is_unified_exec_source(ev.source) {
self.track_unified_exec_process_begin(&ev);
if !self.bottom_pane.is_task_running() {
return;
}
// Unified exec may be parsed as Unknown; keep the working indicator visible regardless.
self.bottom_pane.ensure_status_indicator();
self.track_unified_exec_process_begin(&ev);
if !is_standard_tool_call(&ev.parsed_cmd) {
return;
}
@@ -1832,6 +1836,9 @@ impl ChatWidget {
fn on_exec_command_output_delta(&mut self, ev: ExecCommandOutputDeltaEvent) {
self.track_unified_exec_output_chunk(&ev.call_id, &ev.chunk);
if !self.bottom_pane.is_task_running() {
return;
}
let Some(cell) = self
.active_cell
@@ -3381,6 +3388,9 @@ impl ChatWidget {
SlashCommand::Ps => {
self.add_ps_output();
}
SlashCommand::Clean => {
self.clean_background_terminals();
}
SlashCommand::Mcp => {
self.add_mcp_output();
}
@@ -4467,6 +4477,11 @@ impl ChatWidget {
self.add_to_history(history_cell::new_unified_exec_processes_output(processes));
}
fn clean_background_terminals(&mut self) {
self.submit_op(Op::CleanBackgroundTerminals);
self.add_info_message("Stopping all background terminals.".to_string(), None);
}
fn stop_rate_limit_poller(&mut self) {
if let Some(handle) = self.rate_limit_poller.take() {
handle.abort();
@@ -4,7 +4,7 @@ expression: terminal.backend()
---
" "
"• Working (0s • esc to interrupt) "
" 1 background terminal running · /ps to view "
" 1 background terminal running · /ps to view · /clean to close "
" "
" "
" Ask Codex to do anything "
+70 -2
View File
@@ -3216,6 +3216,22 @@ async fn slash_exit_requests_exit() {
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
async fn slash_clean_submits_background_terminal_cleanup() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.dispatch_command(SlashCommand::Clean);
assert_matches!(op_rx.try_recv(), Ok(Op::CleanBackgroundTerminals));
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected cleanup confirmation message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Stopping all background terminals."),
"expected cleanup confirmation, got {rendered:?}"
);
}
#[tokio::test]
async fn slash_resume_opens_picker() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -4600,6 +4616,42 @@ async fn interrupt_clears_unified_exec_processes() {
let _ = drain_insert_history(&mut rx);
}
#[tokio::test]
async fn review_ended_keeps_unified_exec_processes() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5");
begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6");
assert_eq!(chat.unified_exec_processes.len(), 2);
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent {
reason: TurnAbortReason::ReviewEnded,
}),
});
assert_eq!(chat.unified_exec_processes.len(), 2);
chat.add_ps_output();
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n");
assert!(
combined.contains("Background terminals"),
"expected /ps to remain available after review-ended abort; got {combined:?}"
);
assert!(
combined.contains("sleep 5") && combined.contains("sleep 6"),
"expected /ps to list running unified exec processes; got {combined:?}"
);
let _ = drain_insert_history(&mut rx);
}
#[tokio::test]
async fn interrupt_clears_unified_exec_wait_streak_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -4634,7 +4686,7 @@ async fn interrupt_clears_unified_exec_wait_streak_snapshot() {
}
#[tokio::test]
async fn turn_complete_clears_unified_exec_processes() {
async fn turn_complete_keeps_unified_exec_processes() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5");
@@ -4648,7 +4700,23 @@ async fn turn_complete_clears_unified_exec_processes() {
}),
});
assert!(chat.unified_exec_processes.is_empty());
assert_eq!(chat.unified_exec_processes.len(), 2);
chat.add_ps_output();
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n");
assert!(
combined.contains("Background terminals"),
"expected /ps to remain available after turn complete; got {combined:?}"
);
assert!(
combined.contains("sleep 5") && combined.contains("sleep 6"),
"expected /ps to list running unified exec processes; got {combined:?}"
);
let _ = drain_insert_history(&mut rx);
}
+3
View File
@@ -43,6 +43,7 @@ pub enum SlashCommand {
Feedback,
Rollout,
Ps,
Clean,
Personality,
TestApproval,
}
@@ -68,6 +69,7 @@ impl SlashCommand {
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
SlashCommand::Statusline => "configure which items appear in the status line",
SlashCommand::Ps => "list background terminals",
SlashCommand::Clean => "stop all background terminals",
SlashCommand::Model => "choose what model and reasoning effort to use",
SlashCommand::Personality => "choose a communication style for Codex",
SlashCommand::Plan => "switch to Plan mode",
@@ -124,6 +126,7 @@ impl SlashCommand {
| SlashCommand::Status
| SlashCommand::DebugConfig
| SlashCommand::Ps
| SlashCommand::Clean
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Feedback