Skip to content

fix: propagate cancellation across active turns - #308

Merged
EvanProgramming merged 1 commit into
mainfrom
Evan/v3-cancellation
Oct 21, 2026
Merged

EvanProgramming merged 1 commit into
mainfrom
Evan/v3-cancellation

Conversation

@EvanProgramming

Copy link
Copy Markdown
Owner

Summary

Cancellation now flows from an active chat turn through provider waits, delegated child contexts, tool dispatch boundaries, and foreground shell process groups. Accepted plans and turn history record cancellation; an uncertainty event notes that already-dispatched external effects may still finish. Web clients can cancel with POST /api/chat/{session_id}/cancel, and the TUI has /cancel. Intentional background jobs stay session-owned.

Closes #258

Root cause and approach

An active turn had no shared cancellation signal. A request could not stop its provider wait, shell process tree, or subsequent tool dispatches. The runtime now registers one per-session active-turn signal and propagates it into inherited execution contexts. Shell cancellation terminates the process group/tree using existing platform mechanisms.

Tests

  • Passed focused runtime-event and process-lifecycle tests (28 tests).
  • Passed the delegation integration test (1 test).
  • Passed TUI Go tests, make lint, make check, and make docs-check.
  • make test-core was interrupted after the local volume reached 99% capacity; its Chroma-backed tests hit SQLite disk I/O errors. The broad local run is inconclusive; PR CI is required before merge.

Risks and follow-ups

Cancellation is cooperative for provider requests and tool boundaries. A tool already dispatched to a remote service may complete after cancellation; this is recorded as uncertain. Session background processes remain alive by design and retain their existing session stop controls.

@ghfind-review ghfind-review Bot added review: high ghfind author score; see https://ghfind.com area: cli Terminal UI and interactive agent loop area:cli Command-line and terminal UI bug Something isn't working labels Oct 21, 2026
@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 320e6e65-eef5-4a2b-87fe-1dbc3193597f
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-21T11:01:32.773152Z 32421ce PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 32421ce1d8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread openkyrozen/providers/calls.py Outdated
Comment on lines +37 to +38
if context.cancelled is not None and context.cancelled.is_set():
return context.cancelled

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Re-raise provider cancellation instead of returning an LLM error

When this signal interrupts an active provider wait, bounded_call/bounded_stream raises ProviderError(CANCELLED), but every _get_llm_response path catches that exception and converts it to an [LLM Error] string. The exception therefore never reaches _chat_turn's new cancellation handler: accepted-plan tasks can be marked blocked rather than cancelled, no turn.effect_uncertain event is recorded, and chat() emits turn.failed instead of turn.cancelled. Propagate cancellation errors out of the provider response wrappers so the cancellation cleanup actually runs.

Useful? React with 👍 / 👎.

Comment on lines +437 to +442
async def api_cancel_chat(self, session_id: str, request: Request):
service = self
session_id = service._normalise_session_id(session_id)
user_id = service._actor_for_request(request)
return {"session_id": session_id,
"cancelled": service._agent.cancel_turn(session_id, user_id=user_id)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Run non-streaming chat off the ASGI event loop

For a turn started through POST /api/chat, api_chat is asynchronous but calls the synchronous _run_session_chat directly, blocking the process's event loop for the entire turn. Consequently this new cancellation endpoint cannot be scheduled on the same worker until the turn has already finished; sending it to another multi-process worker does not help because _active_turns is process-local. Dispatch the non-streaming chat work to a thread, as the streaming endpoint already does, so cancellation requests can run concurrently.

Useful? React with 👍 / 👎.

Comment thread tui/slash.go
{name: "agent", aliases: nil, description: "Switch auto, coder, or researcher mode"},
{name: "permissions", aliases: []string{"permission"}, description: "Choose Ask, protected full access, or full access"},
{name: "agents", description: "Inspect parallel agents, results, and independent reviews"},
{name: "cancel", aliases: nil, description: "Cancel the active foreground turn"},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Allow /cancel while the TUI is busy

The Go TUI registers /cancel, but model.submit() returns immediately whenever m.busy is true unless the input is exactly /agents (tui/input.go:604). Since an active foreground turn is precisely what sets the UI busy, users can discover and type this new command but it is never sent to the backend until there is no turn left to cancel. Add /cancel to the commands permitted during the busy state.

Useful? React with 👍 / 👎.

Comment on lines +21 to +24
if command in {"/cancel", "cancel"}:
session = runtime.current_session
cancelled = runtime.cancel_turn(session.session_id, user_id=session.memory.user_id)
self.emit("response", request_id, text="Cancellation requested." if cancelled else "No active turn to cancel.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wake pending approvals when cancelling a TUI turn

When a TUI turn is waiting at a high-impact tool approval, _approval() is blocked on its separate waiter for up to APPROVAL_TIMEOUT_SECONDS (15 minutes). This command only sets the turn cancellation event, so even a programmatic /cancel request leaves the worker and UI busy until the user answers the approval or the timeout expires; the cancellation check in _run_tool cannot run until that wait returns. Cancellation should also deny and signal approvals belonging to the active turn, or the approval wait should observe the turn signal.

Useful? React with 👍 / 👎.

Comment on lines +326 to +327
cancelled=CancellationEvents(self.execution_context.cancelled,
coordinator.cancelled.get(run["run_id"])))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Terminate the parent turn when inherited cancellation fires

When a parent is cancelled while _complete_turn is waiting for delegated work, this combined signal can stop a child's provider/tool operation, but the delegation coordinator determines cancellation solely from its own per-run event. Because that event was not set, it records the child as failed, and _complete_turn treats the terminal failure as an ordinary unresolved result and returns normally, causing the cancelled parent turn to emit completion instead of entering _chat_turn's cancellation cleanup. Propagate inherited cancellation into the coordinator run state and make the waiting parent raise cancellation rather than synthesize a normal result.

Useful? React with 👍 / 👎.

Comment on lines 547 to +549
with self._delegation_tool_access(action, execution_args):
if self.execution_context.cancelled is not None and self.execution_context.cancelled.is_set():
outcome = tool_failure("cancelled", "Tool call was cancelled before execution.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Observe cancellation while waiting for workspace access

This cancellation check runs only after _delegation_tool_access has been entered. If the foreground action is queued behind a sub-agent's overlapping workspace claim, WorkspaceAccess.acquire() may wait indefinitely relative to the cancellation request; for a parent action its callback is always false, and for a child it observes only the coordinator's event rather than the newly inherited parent event. Check the turn signal before entering and pass it into the access wait so cancellation can interrupt lock contention.

Useful? React with 👍 / 👎.

Comment on lines +118 to +120
return CommandResult("Error: command cancelled", False, process.returncode,
"cancelled", out, err, time.monotonic() - started, True,
sandbox_backend=invocation.backend,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not mark cancelled commands as timed out

The positional True passed here populates CommandResult.timed_out, so every command stopped by cancellation is serialized with both failure="cancelled" and timed_out=true. Consumers and receipts therefore cannot distinguish a user cancellation from deadline expiry even though the command was terminated before its timeout. Leave timed_out false for this path, preferably by using named arguments.

Useful? React with 👍 / 👎.

if command in {"/cancel", "cancel"}:
session = runtime.current_session
cancelled = runtime.cancel_turn(session.session_id, user_id=session.memory.user_id)
self.emit("response", request_id, text="Cancellation requested." if cancelled else "No active turn to cancel.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep the TUI busy until the cancelled turn actually exits

Once /cancel is allowed during a busy turn, emitting its acknowledgement as a normal response makes the Go client's response handler replace the current streaming assistant placeholder and immediately set m.busy=false (tui/events.go:138-162). The cancelled worker may still be unwinding, especially for cooperative or external operations, so the UI can accept another submission that the backend rejects as busy and the eventual terminal event can be attributed to an already-finalized placeholder. Send a nonterminal status/ack event here and let the original turn's terminal event clear the busy state.

Useful? React with 👍 / 👎.

Comment on lines +411 to +415
cancellation = threading.Event()
turn_context = copy.copy(self.execution_context)
from openkyrozen.agent.turn import CancellationEvents
turn_context.cancelled = CancellationEvents(turn_context.cancelled, cancellation)
turn_context_token = self._turn_context.set(turn_context)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Classify exceptions raised under a set signal as cancelled

The inner _chat_turn treats any exception raised while this cancellation signal is set as cancellation, but chat()'s outer terminal-event handler only recognizes KeyboardInterrupt, asyncio.CancelledError, and ProviderError(CANCELLED). Cancellation-aware paths such as delegation checks can raise an ordinary RuntimeError, producing a persisted cancelled outcome and uncertainty event inside _chat_turn followed by a contradictory turn.failed terminal event outside it. Include the active signal state in the outer classification so every cooperative cancellation has one consistent terminal event.

Useful? React with 👍 / 👎.

Comment thread openkyrozen/agent/loop.py
Comment on lines +153 to +157
if cancelled:
self._publish_runtime_event("turn.effect_uncertain", {
"reason": "cancelled_during_execution",
"message": "Remote or already-dispatched side effects may still complete.",
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Persist cancellation effect uncertainty in the session journal

_publish_runtime_event only notifies the current in-process observers; it does not append to the durable event store read by /api/v2/events. Consequently this newly emitted uncertainty notice disappears after a non-streaming turn or process restart, despite the documented promise that the session event journal records the possibility of already-dispatched side effects completing. Append a scoped durable event or conversation entry in addition to publishing the transient runtime event.

Useful? React with 👍 / 👎.

@EvanProgramming
EvanProgramming merged commit e3d82b5 into main Oct 21, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: cli Terminal UI and interactive agent loop area:cli Command-line and terminal UI bug Something isn't working review: high ghfind author score; see https://ghfind.com

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[V3] Propagate turn cancellation to providers, tools, processes, and subagents

1 participant