Fix stale background terminal poll events (#23231)

## Why

Issue #23214 reports `/ps` showing no background terminals while the
status line still says it is waiting for a background terminal. The race
is in core: `write_stdin` can poll a process that exits before the
response returns. The process manager correctly returns `process_id:
None`, but the handler still emitted a `TerminalInteraction` event using
the requested session id, causing clients to believe a dead process was
still being polled.

Fixes #23214.

## What changed

- Suppress `TerminalInteraction` events for empty `write_stdin` polls
once `response.process_id` is `None`.
- Continue emitting interactions for non-empty stdin, even if that input
causes the process to exit before the response returns.
- Extend the unified exec integration test to assert completed empty
polls do not emit terminal interactions.

## Verification

- `cargo test -p codex-core --test all
unified_exec_emits_one_begin_and_one_end_event`
- `cargo test -p codex-core --test all
unified_exec_emits_terminal_interaction_for_write_stdin`

`cargo test -p codex-core` currently aborts in unrelated
`agent::control::tests::resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_source_is_stale`
with a reproducible stack overflow.
This commit is contained in:
Eric Traut authored and GitHub committed 2026-05-19 20:48:37 -07:00
1 parent 532b9c83ae
commit e43a2e297f
5 files changed
+46 -11

No files matched your search

@@ -78,14 +78,21 @@ impl ToolExecutor<ToolInvocation> for WriteStdinHandler {
FunctionCallError::RespondToModel(format!("write_stdin failed: {err}"))
})?;
let interaction = TerminalInteractionEvent {
call_id: response.event_call_id.clone(),
process_id: args.session_id.to_string(),
stdin: args.chars.clone(),
};
session
.send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction))
.await;
// Empty stdin is a background poll, so emit it only while there is
// still a live process for the UI to wait on. Non-empty stdin is a real
// terminal interaction and should remain visible even if it completes
// the process before the response returns.
if !args.chars.is_empty() || response.process_id.is_some() {
let process_id = response.process_id.unwrap_or(args.session_id);
let interaction = TerminalInteractionEvent {
call_id: response.event_call_id.clone(),
process_id: process_id.to_string(),
stdin: args.chars.clone(),
};
session
.send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction))
.await;
}
Ok(boxed_tool_output(response))
}
@@ -691,8 +691,8 @@ impl UnifiedExecProcessManager {
// After polling, refresh_process_state tells us whether the PTY is
// still alive or has exited and been removed from the store; we thread
// that through so the handler can tag TerminalInteraction with an
// appropriate process_id and exit_code.
// that through so the handler can tag or suppress TerminalInteraction
// with an appropriate process_id and exit_code.
let status = if let Some(status) = status_after_write {
status
} else {