Repository navigation
fix: Stop control signals from being dropped or ignored - #791
AlexanderZ-Band wants to merge 16 commits into
Conversation
AlexanderZ-Band
left a comment
There was a problem hiding this comment.
Review of PR #791 (STOP/INTERRUPT ends the room's turn on every path).
Blocking: two confirmed stop-before-bind gaps (codex turn/start window, a2a continuation), the lost exception detail on failed backend stops, and test-quality issues (tautological/over-broad letta checks, scheduler-dependent opencode check, missing bridge ERROR-branch and scenario 14/16/18 coverage).
Edge-case/logic hypotheses dropped after reproduction: participant events during hydration (never reach adapters), claude_sdk retire ordering (worker cancelled first), _admit_turn closing re-check, overlapping running blocks, codex teardown drain (_retire_room refuses while stopping), bridge nudge /next window (pre-existing).
AlexanderZ-Band
left a comment
There was a problem hiding this comment.
Cycle 2 (fix-area review of 8674d21..a972bc7): converged, with no confirmed defect.
Dropped after reproduction:
- a2a continuation answered with a different task id: the a2a-sdk reference handlers reject a mismatched or terminal task id.
- codex
turn/startslower than the 5s stop budget: the mechanism reproduces, but no production path reaches it (no request timeout, codex-rs answers after in-process setup, retry backoff is at most about 0.85s).
The remaining nits and suggestions are posted inline.
e0269b3 to
5954a7f
Compare
…in on STOP A control that landed while a turn was still hydrating found no running scope, because admission was set in four places and only after setup awaits. One _turn_scope() context manager now creates the scope controllable and settles it on every exit; TurnScope.admitted is gone. A STOP during the contact-hub drain re-queued the event and popped it again without yielding, freezing the event loop. The drain now returns STOPPED while the room is stopped and leaves the queue for PLAY. Refs INT-1133, INT-1781 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLMomHCoGDhHacCw5hWsuA
Stopping an external backend's turn was hand-written per adapter, in three different shapes, and missing in seven adapters. BackendTurns owns the rules once: register the turn around the owning task's backend call, stop it at most once in an owned task bounded by BACKEND_CANCEL_TIMEOUT_SECONDS, never await it on the control-processing task, and make the room's next turn and teardown wait for it. An adapter supplies only its handle and cancel call and opts in through SimpleAdapter.backend_turns, which the default on_interrupt cancels. Refs INT-1133 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLMomHCoGDhHacCw5hWsuA
…Turns The ACP client's shielded session/cancel from its CancelledError branch and claude_sdk's CLI retirement from the worker's CancelledError branch both move to BackendTurns: the owning task holds running() around the backend call, outside the turn timeout, and the stop runs once in an owned task. Timeout branches keep their own reporting and call cancel + settled. backend_turns becomes a read-only property each adapter narrows. The approval mixin gives a pending backend stop one cleanup grace before it refuses new input or reports retirement incomplete, and its on_interrupt calls super() first so the default cancel still runs. Refs INT-1133 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLMomHCoGDhHacCw5hWsuA
codex, cursor_acp, copilot_sdk, opencode, letta, a2a and parlant now stop their backend turn when the room's turn ends early: codex sends turn/interrupt, copilot aborts the session, opencode cancels its watcher and aborts the session once, letta cancels the room agent's or conversation's runs, a2a cancels the in-flight task, and parlant cancels the engine's process-session task. Each supplies only its handle and cancel call; timeouts, the next turn and teardown join the stop. tests/framework_conformance/test_backend_stop.py fails for any band adapter that is neither probed against a recording backend nor pinned with a NoBackendTurn reason. Letta and a2a gain recording fakes, Parlant a stub model so its probe runs the real engine. Refs INT-1133 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLMomHCoGDhHacCw5hWsuA
_rehydrate_backlog gathered per-room nudges without return_exceptions, so a STOP that cancelled one room's nudge raised out of the gather and shut the bridge down. Results are now collected: a cancelled nudge is logged at INFO, any other failure at ERROR, and outer cancellation still propagates. The bridge tests move their shared runners and forwarders into kit.py and split to one class per file. Refs INT-1133 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLMomHCoGDhHacCw5hWsuA
BackendTurns.stop replaces the cancel-then-settled pair each timeout branch repeated; the stop probes share one stop_room helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLMomHCoGDhHacCw5hWsuA
A STOP during codex's turn/start round-trip, or before a continued A2A task's first event, dropped the requested stop: the owner exited before binding a handle. BackendTurns.started lets the starting call finish and stops the turn it reveals; A2A passes a continued task's id up front. A stop that never reached a backend is logged, and a failed backend stop keeps its exception type and traceback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLMomHCoGDhHacCw5hWsuA
Probes for codex's turn/start window, A2A continuation, shutdown right after a STOP, and a failed bridge rehydration nudge; letta, opencode and codex probes now assert decided outcomes rather than scheduling or always-true facts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLMomHCoGDhHacCw5hWsuA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLMomHCoGDhHacCw5hWsuA
…11 timing assumptions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLMomHCoGDhHacCw5hWsuA
Parlant's preamble task records its own typing/ready statuses beside the stopped turn's, so on a slower runner the probe read the preamble's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLMomHCoGDhHacCw5hWsuA
Parlant's cancelled/ready status records don't land reliably on Windows, though the engine does cancel. The stub model never answers, so the session's process-session task finishes only when it is stopped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CLMomHCoGDhHacCw5hWsuA
2f28309 to
4397d79
Compare
…d-or-ignor-INT-1133
…nterruptstop-are-dropped-or-ignor-INT-1133 Co-authored-by: Cursor <cursoragent@cursor.com>
bandzalkin
left a comment
There was a problem hiding this comment.
Scope
Adversarial and architectural review of 6546c2c against 69e15a2. Four new reproduced blocking findings are inline (F1–F4). Prior resolved findings and accepted decisions were reconciled, not duplicated.
Constraints
Cancellation must stop backend work without blocking control processing, preserve originating-turn identity, and fence replacement work until stopping settles. Current main (d7ab342) also contains the retained-room ownership cutover from #709.
Decisions
Keep BackendTurns as the remote-cancellation mechanism, but enforce settlement before preparing replacement turn state, retain ownership across timeout transitions, and include engine-owned child generation in completion. Integrate with main's canonical retained-room owner rather than restoring obsolete retirement machinery.
Findings and proof
F1–F4 are P2 merge blockers: OpenCode timeout-abort ownership, Parlant child-generation ownership, ACP rollover isolation, and A2A bootstrap identity. Details and smallest corrections are inline. Focused tests: 510 passed, 4 skipped. Broader affected-consumer tests: 3,681 passed, 72 skipped. Ruff lint/format and Pyrefly passed. Additional behavioral probes reproduced every finding; F1 was compared against the base. Four real-engine Parlant probe outputs matched their expectations, but the additional child-task probe exposed F2. The Parlant pytest lane could not collect in the partial-dependency environment, so engine probes ran directly. No live-provider billing or production late delivery was exercised; no full-repository or merged-result sign-off is claimed.
Runtime admission/setup, synthetic STOP draining and bridge cancellation-isolation attacks were disproved. The PR also currently has merge conflicts in lifecycle owners and related tests; the resolved integration needs fresh verification and review.
Architectural verdict
HELD — not merge-ready. Resolve F1–F4 and the current-main integration conflicts, then verify and re-review the resulting revision.
A timeout abort, a Parlant preamble child, an ACP session reset, and an A2A bootstrap could all proceed while the room's stop was still unfinished, so a STOP dropped the abort or the next turn resumed work the cancel had not finished. Co-authored-by: Cursor <cursoragent@cursor.com>
bandzalkin
left a comment
There was a problem hiding this comment.
Scope
Re-review of 4d8c5cffa789fdc980df2a59eaefa04ce2ffb5d6, focusing on the fixes since 6546c2cc; aggregate PR base 69e15a20. Previous discussions and dispositions were reconciled.
Constraints
Backend cancellation must retain ownership until settlement, fence replacement-turn preparation, and include engine-owned child generation.
Decisions
Keep the shared BackendTurns mechanism. The OpenCode timeout-abort ownership fix (F1), ACP rollover barrier (F3), and A2A bootstrap barrier (F4) address the prior findings in the exercised paths. Parlant's child ownership remains incomplete.
Findings and proof
One failed-fix finding is inline on the current diff: F2 remains blocking, linked to its original discussion. No separate new blocker was confirmed. OpenCode held-abort STOP/INTERRUPT probes passed; ACP/A2A rollover regressions passed. The real Parlant 3.3.2 probe failed at preamble discovery, and independent real-engine diagnostics confirmed surviving child generation and accumulation across turns. Details are inline.
Focused checks: 371 passed, 4 skipped. Broader core/runtime/bridge/ACP/conformance checks: 3,867 passed, 73 skipped. Changed-file Ruff lint/format and project Pyrefly passed. The dev-environment skips include Parlant; its engine was exercised separately with a parked model boundary. The Parlant pytest lane could not collect in the isolated dev-parlant environment because conformance setup required the absent langgraph extra. No full-repository, live-provider, or merged-result sign-off is claimed.
GitHub still reports CONFLICTING / DIRTY; the resolved integration requires fresh verification and review.
Architectural verdict
HELD — not merge-ready. Correct F2, resolve the merge conflicts, and verify/re-review the resulting revision.
Parlant 3.3.2 compiles the preamble child on AlphaEngine, so the Engine qualname never found it and a stop left that generation running. Co-authored-by: Cursor <cursoragent@cursor.com>
|
The merge-conflict note in the 2026-10-15 re-review is real: this branch still conflicts with main after the retained-room owner (#709), stopped-room polling (#804), and the OpenAI adapter split (#802). The overlapping lifecycle files are listed on INT-1809. That integration is outside the Parlant preamble-identity fix. Tracked in https://linear.app/thenvoi/issue/INT-1809/integrate-pr-791s-backend-stop-with-mains-retained-room-owner |
Why
A STOP or INTERRUPT should end the room's turn, but three layers each owned part of that and none owned all of it:
What
_turn_scope()is the only admission point. A scope can be terminated from the moment it is created and is settled on every exit, andTurnScope.admittedis removed. The synthetic drain returnsSTOPPEDwhile the room is stopped and leaves its queue for PLAY.BackendTurns(inband.core.turn_lifecycle) is the shared way to stop a room's backend turn:running()around it, outside any timeout scope.BACKEND_CANCEL_TIMEOUT_SECONDS, and never awaited on the control-processing task.started()lets the call that starts the turn finish and stops the turn it names (codexturn/start); a continued a2a task is registered with its id up front.backend_turnsproperty; the defaulton_interruptcancels through it.session/cancel. The next turn waits for that cancel before it resets the session or installs its handlers.session/cancel, inherited from the ACP client.turn/interrupt.session.abort().tasks/cancel. A bootstrap waits for a pending cancel before it rehydrates or addresses a task.process-sessiontask and the preamble child that task leaves running.tests/framework_conformance/test_backend_stop.pyfails for any band adapter that is neither probed against a recording backend nor pinned with aNoBackendTurnreason.kit.pyplus one class per file.Scope
Closes INT-1133 and INT-1781. Routing a control to a room that is still starting is followup INT-1782. No config, public signature or wire shape changes.
TurnScope.admittedwas internal. Merging this branch with main's retained-room lifecycle is followup INT-1809.🤖 Generated with Claude Code
https://claude.ai/code/session_01CLMomHCoGDhHacCw5hWsuA