Skip to content

[codex] fix runtime cancellation state sync - #1638

Merged
qdaxb merged 2 commits into
mainfrom
fix/runtime-cancel-state-sync
Jun 30, 2026
Merged

qdaxb merged 2 commits into
mainfrom
fix/runtime-cancel-state-sync

Conversation

@qdaxb

@qdaxb qdaxb commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Stop Codex runtime turns through cancellable app-server turns and process-group termination.
  • Wait for runtime cancellation to actually finish and surface cancel_timeout instead of treating a live turn as stopped.
  • Refresh Wework runtime work after cancellation and settle pane streaming assistant messages with assistant_cancelled.
  • Add regression coverage for Codex process-group cancellation and Wework runtime/message state convergence.
  • Document cancellation state convergence in the task runtime state machine guide.

Root Cause

Stopping previously could halt the stream or parent process while the underlying Codex app-server turn, or its child worker processes, continued running. On the frontend, runtime work state and pane message streaming state were also updated through separate paths, so one UI surface could show stopped while another still showed running.

Verification

  • ./node_modules/.bin/vitest run src/features/workbench/WorkbenchProvider.test.tsx
  • ../../node_modules/.bin/vitest run src/workbench-message-reducer.test.ts
  • ../../node_modules/.bin/tsc -p tsconfig.json --noEmit
  • cargo fmt && uv run cargo test --test app_runtime_work_send_contract --test codex_app_server_contract
  • Pre-push Wework coverage: 103 test files, 964 tests passed
  • Pre-push executor checks: cargo fmt --check, cargo test --all-features, cargo clippy

Summary by CodeRabbit

  • New Features

    • Added support for cancelling a running response/turn, with the UI now reflecting cancelled assistant messages.
    • Runtime/task status is refreshed after cancellation so sidebars and composer state stay in sync.
  • Bug Fixes

    • Improved cancellation handling to avoid cases where the app still appears busy after stopping a response.
    • Stopped responses now finalize more reliably, including in streaming and worker-process scenarios.
  • Tests

    • Expanded coverage for cancellation, runtime-state refresh, and streaming-message handling.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds end-to-end cooperative cancellation for runtime tasks: the executor gains process-group management and oneshot cancel channels for Codex turns; the frontend message reducer gains an assistant_cancelled action and runtimeStatus/stoppedNotice fields on WorkbenchMessage; useWorkbenchPaneSession exposes pauseCurrentResponse; layout components are rewired to call it; and the work list is refreshed after cancellation.

Changes

Runtime Task Cancellation

Layer / File(s) Summary
Codex process-group termination
executor/src/agents/codex.rs
Adds CODEX_APP_SERVER_TURN_CANCELLED constant, Unix process-group setup via configure_codex_app_server_process_group, centralized terminate_codex_app_server_child/signal_codex_app_server_child helpers sending SIGTERM then SIGKILL to the process group, and kill_on_drop(true); updates Drop/shutdown/error paths to use these helpers.
Cancellable turn execution API
executor/src/agents/codex.rs, executor/src/agents/mod.rs
Introduces run_codex_app_server_turn_with_cancel accepting Option<oneshot::Receiver<()>>; uses tokio::select! to race rpc.read_turn against cancellation; run_codex_app_server_turn forwards with None; re-exports both new items from agents/mod.rs.
Handler cancel channels and abort logic
executor/src/runtime_work/handler.rs
RuntimeWorkRpcHandler gains active_turn_cancellations: HashMap<String, ActiveTurnCancellation>; spawn_turn creates/registers per-turn oneshot channels and handles CODEX_APP_SERVER_TURN_CANCELLED early exit; cancel_task calls new abort_active_turn and returns cancel_timeout on 10-second timeout; finish_local_task clears cancel entries.
store.rs cleanup
executor/src/runtime_work/store.rs
Simplifies temp-file removal condition in write_index to a single combined write && rename.is_err() guard.
WorkbenchMessage cancellation types and reducer
packages/chat-core/src/workbench-message-reducer.ts
Adds WorkbenchRuntimeMessageStatus type, runtimeStatus/completedAt/stoppedNotice fields, assistant_cancelled action variant, and the corresponding reducer branch that finalizes streaming messages and their blocks with runtimeStatus: 'cancelled'; updates assistant_chunk to use getChunkBlocks and assistant_error to finalize blocks.
pauseCurrentResponse in pane session and runtime messaging
wework/src/components/layout/useWorkbenchPaneSession.ts, wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts
Adds pauseCurrentResponse callback: cancels runtime task via cancelRuntimePaneTask, dispatches assistant_cancelled, then awaits refreshWorkLists(); queued guidance path dispatches assistant_cancelled instead of assistant_done.
Layout wiring
wework/src/components/layout/DesktopWorkbenchMain.tsx, wework/src/components/layout/MobileWorkbenchLayout.tsx
Replaces pauseCurrentResponse(paneMessages) from context with paneSession.pauseCurrentResponse() across all ChatInput onPause and TaskForkDialog onStopCurrentResponse call sites.
Executor cancel integration tests
executor/tests/app_runtime_work_send_contract.rs, executor/tests/app_runtime_work_contract.rs
Two new cancel tests verify process-group termination for signal-trapping and worker-spawning Codex scripts; adds fake scripts logging PIDs, polling helpers wait_for_logged_pid/assert_process_exited, targeted wait_for_response_event helper, and thread/name/set RPC handling in existing scripts.
Frontend tests
packages/chat-core/src/workbench-message-reducer.test.ts, wework/src/features/workbench/WorkbenchProvider.test.tsx
Adds assistant_cancelled reducer test; updates fixtures with createdAt; adds WorkbenchProvider integration test asserting work-list refresh, running-indicator flip, and removal of assistant:streaming after stop; extends RuntimeOpenProbe with runtime-message-statuses, running indicator, and stop button.
Cancellation docs
docs/en/developer-guide/task-runtime-state-machine.md, docs/zh/developer-guide/task-runtime-state-machine.md
Adds "Cancellation And State Convergence" section in English and Chinese specifying the two runtime state surfaces, cancellation ordering rules, process-group termination, work-list refresh, and local message finalization.

Sequence Diagram

sequenceDiagram
  participant UI as ChatInput / TaskForkDialog
  participant PaneSession as useWorkbenchPaneSession
  participant RuntimeMsg as useWorkbenchRuntimeMessaging
  participant Executor as RuntimeWorkRpcHandler
  participant Codex as run_codex_app_server_turn_with_cancel
  participant OS as Unix Process Group

  UI->>PaneSession: pauseCurrentResponse()
  PaneSession->>RuntimeMsg: cancelRuntimePaneTask()
  RuntimeMsg->>Executor: runtime.tasks.cancel
  Executor->>Executor: abort_active_turn — send cancel_tx
  Executor->>Codex: oneshot cancel fires
  Codex->>Codex: tokio::select! cancellation wins
  Codex->>OS: SIGTERM → SIGKILL to process group
  Codex-->>Executor: Err(CODEX_APP_SERVER_TURN_CANCELLED)
  Executor-->>RuntimeMsg: accepted: true
  RuntimeMsg->>RuntimeMsg: refreshWorkLists()
  RuntimeMsg-->>PaneSession: success
  PaneSession->>PaneSession: dispatch assistant_cancelled
  PaneSession-->>UI: streaming messages marked done/cancelled
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • wecode-ai/Wegent#1254: Adds/routes the onPause callback in ChatInput during assistant streaming, which is the same call site now rewired to paneSession.pauseCurrentResponse() in this PR.
  • wecode-ai/Wegent#1393: Modifies workbench-message-reducer.ts block finalization and terminal handling paths (assistant_done/assistant_error) that are extended here for assistant_cancelled.

Suggested reviewers

  • feifei325

🐇 A turn was spinning, no end in sight,
So the rabbit sent a signal to fight—
SIGTERM to the group, then SIGKILL too,
Flushed the work list, marked messages through.
Now cancel resolves with state aligned just right! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately reflects the main change: fixing runtime cancellation state synchronization.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/runtime-cancel-state-sync

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ate-sync

# Conflicts:
#	executor/tests/app_runtime_work_send_contract.rs
@qdaxb
qdaxb marked this pull request as ready for review June 30, 2026 09:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
executor/src/runtime_work/handler.rs (1)

686-706: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Store is marked cancelled before the turn is confirmed stopped.

update_task sets status = "cancelled", running = false (Lines 691-695) before abort_active_turn is awaited. On the cancel_timeout path the turn is still alive and will later run finish_local_task, overwriting status again — so the persisted state transiently disagrees with the live turn. Consider deferring the store mutation until after abort_active_turn succeeds, or documenting that cancel_timeout leaves a non-converged record.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@executor/src/runtime_work/handler.rs` around lines 686 - 706, The cancel flow
in cancel_task updates the persisted task state to cancelled before
abort_active_turn confirms the turn has actually stopped, which can leave the
store out of sync on the cancel_timeout path. Move the store mutation in
cancel_task so it only happens after abort_active_turn succeeds, or otherwise
make the intended non-converged behavior explicit in the task record handling.
Keep the localTaskId lookup and the abort_active_turn path as the key decision
points when adjusting the logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@executor/src/agents/codex.rs`:
- Around line 502-517: In signal_codex_app_server_child, SIGTERM and SIGKILL are
being sent immediately one after the other, so the process group has no time to
exit cleanly. Update the Unix path to send SIGTERM first, then wait briefly or
poll try_wait on the Child before escalating to libc::SIGKILL, keeping the same
process-group targeting logic around child.id().

In `@executor/tests/app_runtime_work_send_contract.rs`:
- Around line 868-908: The helper write_fake_codex_traps_termination does not
actually trap TERM, so the runtime_tasks_cancel_terminates_running_codex_turn
test is not exercising the intended SIGKILL-after-trapped-SIGTERM behavior.
Update the shell script generated in write_fake_codex_traps_termination to
install a TERM trap similar to write_fake_codex_spawns_worker, while keeping the
existing logging and turn/start blocking behavior intact, so the fake Codex
process survives SIGTERM until the test sends SIGKILL.

---

Outside diff comments:
In `@executor/src/runtime_work/handler.rs`:
- Around line 686-706: The cancel flow in cancel_task updates the persisted task
state to cancelled before abort_active_turn confirms the turn has actually
stopped, which can leave the store out of sync on the cancel_timeout path. Move
the store mutation in cancel_task so it only happens after abort_active_turn
succeeds, or otherwise make the intended non-converged behavior explicit in the
task record handling. Keep the localTaskId lookup and the abort_active_turn path
as the key decision points when adjusting the logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d92f1a9d-2260-4a83-b536-24d998ec7cf9

📥 Commits

Reviewing files that changed from the base of the PR and between 11ab12e and 1d6d92f.

📒 Files selected for processing (15)
  • docs/en/developer-guide/task-runtime-state-machine.md
  • docs/zh/developer-guide/task-runtime-state-machine.md
  • executor/src/agents/codex.rs
  • executor/src/agents/mod.rs
  • executor/src/runtime_work/handler.rs
  • executor/src/runtime_work/store.rs
  • executor/tests/app_runtime_work_contract.rs
  • executor/tests/app_runtime_work_send_contract.rs
  • packages/chat-core/src/workbench-message-reducer.test.ts
  • packages/chat-core/src/workbench-message-reducer.ts
  • wework/src/components/layout/DesktopWorkbenchMain.tsx
  • wework/src/components/layout/MobileWorkbenchLayout.tsx
  • wework/src/components/layout/useWorkbenchPaneSession.ts
  • wework/src/features/workbench/WorkbenchProvider.test.tsx
  • wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts

Comment on lines +502 to +517
fn signal_codex_app_server_child(child: &mut Child) {
if matches!(child.try_wait(), Ok(Some(_))) {
return;
}

#[cfg(unix)]
if let Some(process_group_id) = child.id() {
unsafe {
let _ = libc::kill(-(process_group_id as libc::pid_t), libc::SIGTERM);
let _ = libc::kill(-(process_group_id as libc::pid_t), libc::SIGKILL);
}
return;
}

let _ = child.start_kill();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

libc setpgid pre_exec child process group kill negative pid ESRCH tokio

💡 Result:

The ESRCH error in the context of sending signals to process groups or setting process group IDs generally stems from a mismatch between the expected PID/PGID and the actual state of the process table [1][2][3]. Killing a Process Group with a Negative PID: When using the kill system call with a negative PID (e.g., kill(-pgid, sig)), the kernel attempts to send the signal to all processes in the process group whose ID equals the absolute value of that PID [4]. An ESRCH error occurs if no process group exists with that ID [4][3]. A common mistake is assuming that a PID is always a valid PGID; however, only the process group leader has a PGID equal to its PID [5][3]. If you wish to signal an entire group, you must ensure you are passing the correct PGID, which can be retrieved via getpgid(pid) [3]. setpgid in pre_exec: When using pre_exec (often in Rust's tokio or std::process) to set the process group of a child process, the setpgid call must occur after fork but before the child executes a new program via execve [6][7]. The setpgid call itself may return ESRCH if the specified process is not the calling process or its child, or if the process has already executed an exec function (which typically triggers EACCES, though behavior with zombies or race conditions can sometimes lead to ESRCH) [1][2][8]. Tokio and Process Groups: To manage process groups in Tokio, the recommended approach is to use the.process_group(pgid) method on tokio::process::Command [9]. This method is stable and allows you to set the PGID for the child process without manually managing pre_exec or unsafe blocks, avoiding potential errors associated with incorrect timing or invalid PID/PGID assumptions [6][9][10]. If you must use pre_exec for custom logic, ensure that you only call setpgid on the child process itself (using getpid inside the child) or its direct children, and handle potential errors gracefully [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== codex.rs outline ==\n'
ast-grep outline executor/src/agents/codex.rs --view expanded || true

printf '\n== Relevant slices ==\n'
sed -n '380,540p' executor/src/agents/codex.rs | cat -n

printf '\n== Search for process group setup / termination paths ==\n'
rg -n "setpgid|process_group|start_kill\(|wait\(|try_wait\(|kill\(-" executor/src/agents/codex.rs executor/src -g '!**/target/**' || true

Repository: wecode-ai/Wegent

Length of output: 18312


Add a short grace window before escalating to SIGKILL. SIGTERM and SIGKILL are sent back-to-back here, so the child never gets a chance to shut down cleanly. Wait briefly or poll for exit before killing the group.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@executor/src/agents/codex.rs` around lines 502 - 517, In
signal_codex_app_server_child, SIGTERM and SIGKILL are being sent immediately
one after the other, so the process group has no time to exit cleanly. Update
the Unix path to send SIGTERM first, then wait briefly or poll try_wait on the
Child before escalating to libc::SIGKILL, keeping the same process-group
targeting logic around child.id().

Comment on lines +868 to +908
fn write_fake_codex_traps_termination(log_path: &Path) -> PathBuf {
let path = temp_path("fake-codex-cancel-kill", "sh");
let _ = fs::remove_file(log_path);
let content = format!(
r#"#!/bin/sh
LOG_PATH='{}'
case "$*" in
*' -c '*|'-c '*)
printf 'turn-pid:%s\n' "$$" >> "$LOG_PATH"
;;
*)
printf 'persistent-pid:%s\n' "$$" >> "$LOG_PATH"
;;
esac
while IFS= read -r line; do
printf '%s\n' "$line" >> "$LOG_PATH"
request_id=$(printf '%s\n' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
case "$line" in
*'"method":"initialize"'*)
printf '%s\n' '{{"id":'"$request_id"',"result":{{"protocolVersion":1}}}}'
;;
*'"method":"initialized"'*)
;;
*'"method":"thread/start"'*)
printf '%s\n' '{{"id":'"$request_id"',"result":{{"thread":{{"id":"thread-1"}}}}}}'
;;
*'"method":"thread/name/set"'*)
printf '%s\n' '{{"id":'"$request_id"',"result":{{}}}}'
;;
*'"method":"turn/start"'*)
printf '%s\n' '{{"id":'"$request_id"',"result":{{"turn":{{"id":"turn-1","status":"inProgress"}}}}}}'
while true; do sleep 1; done
;;
esac
done
"#,
log_path.display()
);
write_executable(&path, &content);
path
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

write_fake_codex_traps_termination never installs a signal trap.

The helper name and the companion test (runtime_tasks_cancel_terminates_running_codex_turn) imply the fake process traps termination so the test proves SIGKILL still wins. But this script has no trap command, so a plain SIGTERM already terminates it — the SIGKILL-vs-trapped-SIGTERM scenario isn't actually exercised. Compare with write_fake_codex_spawns_worker (Line 938), which correctly uses trap "" TERM.

🧪 Add a TERM trap to match the intent
 LOG_PATH='{}'
+trap "" TERM
 case "$*" in
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn write_fake_codex_traps_termination(log_path: &Path) -> PathBuf {
let path = temp_path("fake-codex-cancel-kill", "sh");
let _ = fs::remove_file(log_path);
let content = format!(
r#"#!/bin/sh
LOG_PATH='{}'
case "$*" in
*' -c '*|'-c '*)
printf 'turn-pid:%s\n' "$$" >> "$LOG_PATH"
;;
*)
printf 'persistent-pid:%s\n' "$$" >> "$LOG_PATH"
;;
esac
while IFS= read -r line; do
printf '%s\n' "$line" >> "$LOG_PATH"
request_id=$(printf '%s\n' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
case "$line" in
*'"method":"initialize"'*)
printf '%s\n' '{{"id":'"$request_id"',"result":{{"protocolVersion":1}}}}'
;;
*'"method":"initialized"'*)
;;
*'"method":"thread/start"'*)
printf '%s\n' '{{"id":'"$request_id"',"result":{{"thread":{{"id":"thread-1"}}}}}}'
;;
*'"method":"thread/name/set"'*)
printf '%s\n' '{{"id":'"$request_id"',"result":{{}}}}'
;;
*'"method":"turn/start"'*)
printf '%s\n' '{{"id":'"$request_id"',"result":{{"turn":{{"id":"turn-1","status":"inProgress"}}}}}}'
while true; do sleep 1; done
;;
esac
done
"#,
log_path.display()
);
write_executable(&path, &content);
path
}
fn write_fake_codex_traps_termination(log_path: &Path) -> PathBuf {
let path = temp_path("fake-codex-cancel-kill", "sh");
let _ = fs::remove_file(log_path);
let content = format!(
r#"#!/bin/sh
LOG_PATH='{}'
trap "" TERM
case "$*" in
*' -c '*|'-c '*)
printf 'turn-pid:%s\n' "$$" >> "$LOG_PATH"
;;
*)
printf 'persistent-pid:%s\n' "$$" >> "$LOG_PATH"
;;
esac
while IFS= read -r line; do
printf '%s\n' "$line" >> "$LOG_PATH"
request_id=$(printf '%s\n' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
case "$line" in
*'"method":"initialize"'*)
printf '%s\n' '{{"id":'"$request_id"',"result":{{"protocolVersion":1}}}}'
;;
*'"method":"initialized"'*)
;;
*'"method":"thread/start"'*)
printf '%s\n' '{{"id":'"$request_id"',"result":{{"thread":{{"id":"thread-1"}}}}}}'
;;
*'"method":"thread/name/set"'*)
printf '%s\n' '{{"id":'"$request_id"',"result":{{}}}}'
;;
*'"method":"turn/start"'*)
printf '%s\n' '{{"id":'"$request_id"',"result":{{"turn":{{"id":"turn-1","status":"inProgress"}}}}}}'
while true; do sleep 1; done
;;
esac
done
"#,
log_path.display()
);
write_executable(&path, &content);
path
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@executor/tests/app_runtime_work_send_contract.rs` around lines 868 - 908, The
helper write_fake_codex_traps_termination does not actually trap TERM, so the
runtime_tasks_cancel_terminates_running_codex_turn test is not exercising the
intended SIGKILL-after-trapped-SIGTERM behavior. Update the shell script
generated in write_fake_codex_traps_termination to install a TERM trap similar
to write_fake_codex_spawns_worker, while keeping the existing logging and
turn/start blocking behavior intact, so the fake Codex process survives SIGTERM
until the test sends SIGKILL.

@qdaxb
qdaxb merged commit bd21451 into main Jun 30, 2026
28 checks passed
@qdaxb
qdaxb deleted the fix/runtime-cancel-state-sync branch June 30, 2026 09:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant