Skip to content

fix: Stop control signals from being dropped or ignored - #791

Open
AlexanderZ-Band wants to merge 16 commits into
mainfrom
fix/control-signals-interruptstop-are-dropped-or-ignor-INT-1133
Open

AlexanderZ-Band wants to merge 16 commits into
mainfrom
fix/control-signals-interruptstop-are-dropped-or-ignor-INT-1133

Conversation

@AlexanderZ-Band

@AlexanderZ-Band AlexanderZ-Band commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  • Runtime: a turn only became controllable after its setup awaits, so a control during hydration was lost. A STOP during the contact-hub drain also re-queued events in a loop that never yielded, which froze the event loop.
  • Adapters: stopping an external backend's turn was hand-written in three different shapes and missing from seven adapters, so stopped turns kept generating.
  • Bridge: a STOP during startup rehydration crashed the whole bridge.

What

  • Runtime: _turn_scope() is the only admission point. A scope can be terminated from the moment it is created and is settled on every exit, and TurnScope.admitted is removed. The synthetic drain returns STOPPED while the room is stopped and leaves its queue for PLAY.
  • BackendTurns (in band.core.turn_lifecycle) is the shared way to stop a room's backend turn:
    • The task that owns the backend call holds running() around it, outside any timeout scope.
    • The stop is sent at most once, from a task the primitive owns, bounded by BACKEND_CANCEL_TIMEOUT_SECONDS, and never awaited on the control-processing task.
    • The room's next turn and its teardown wait for the stop to finish.
    • A stop that lands before the backend reveals its turn still reaches it: started() lets the call that starts the turn finish and stops the turn it names (codex turn/start); a continued a2a task is registered with its id up front.
    • An adapter supplies only its handle and its cancel call, through the backend_turns property; the default on_interrupt cancels through it.
  • Eleven backend-driving adapters now use it:
    • ACP client, plus copilot_acp and omp_acp by inheritance: session/cancel. The next turn waits for that cancel before it resets the session or installs its handlers.
    • cursor_acp: session/cancel, inherited from the ACP client.
    • claude_sdk: CLI retirement.
    • codex: turn/interrupt.
    • copilot_sdk: session.abort().
    • opencode: watcher cancel, then a single session abort. A timeout abort stays on that owned stop, and the timeout report is not cancelled by its own stop.
    • letta: agent or conversation run cancel.
    • a2a: tasks/cancel. A bootstrap waits for a pending cancel before it rehydrates or addresses a task.
    • parlant: cancels the engine's process-session task and the preamble child that task leaves running.
  • Guard: 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.
  • Bridge: rehydration collects nudge results instead of failing on one cancellation. The bridge tests are split into kit.py plus 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.admitted was internal. Merging this branch with main's retained-room lifecycle is followup INT-1809.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CLMomHCoGDhHacCw5hWsuA

@linear-code

linear-code Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

INT-1781

INT-1133

INT-1809

@AlexanderZ-Band
AlexanderZ-Band requested review from a team and amit-gazal-band October 13, 2026 11:09
@AlexanderZ-Band
AlexanderZ-Band marked this pull request as ready for review October 13, 2026 11:09

@AlexanderZ-Band AlexanderZ-Band left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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).

Comment thread src/band/adapters/codex.py
Comment thread src/band/integrations/a2a/adapter.py Outdated
Comment thread src/band/core/turn_lifecycle.py
Comment thread src/band/core/turn_lifecycle.py
Comment thread src/band/runtime/execution.py Outdated
Comment thread tests/framework_conformance/test_backend_stop.py Outdated
Comment thread tests/runtime/executioninterrupt/test_setup_window_control.py Outdated
Comment thread tests/bridge/test_rehydration_control.py
Comment thread tests/framework_conformance/test_backend_stop.py
Comment thread tests/runtime/resync/test_stopped_drain.py Outdated

@AlexanderZ-Band AlexanderZ-Band left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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/start slower 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.

Comment thread src/band/core/turn_lifecycle.py Outdated
Comment thread src/band/core/turn_lifecycle.py Outdated
Comment thread src/band/core/retention/cleanup.py
Comment thread tests/framework_conformance/stopprobes/copilotsdk.py
Comment thread tests/core/test_backend_turns.py Outdated
Comment thread tests/core/test_backend_turns.py Outdated
Comment thread tests/core/test_backend_turns.py Outdated
Comment thread tests/bridge/test_rehydration_failure.py Outdated
@AlexanderZ-Band
AlexanderZ-Band force-pushed the fix/control-signals-interruptstop-are-dropped-or-ignor-INT-1133 branch from e0269b3 to 5954a7f Compare October 13, 2026 12:27
AlexanderZ-Band and others added 12 commits October 13, 2026 16:12
…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
…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
@AlexanderZ-Band
AlexanderZ-Band force-pushed the fix/control-signals-interruptstop-are-dropped-or-ignor-INT-1133 branch from 2f28309 to 4397d79 Compare October 13, 2026 13:17
AlexanderZ-Band and others added 2 commits October 13, 2026 20:29
…nterruptstop-are-dropped-or-ignor-INT-1133

Co-authored-by: Cursor <cursoragent@cursor.com>

@bandzalkin bandzalkin 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.

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.

Comment thread src/band/adapters/opencode/adapter.py Outdated
Comment thread src/band/adapters/parlant/adapter.py Outdated
Comment thread src/band/integrations/acp/client_adapter.py
Comment thread src/band/integrations/a2a/adapter.py
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 bandzalkin 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.

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.

Comment thread src/band/adapters/parlant/messages.py Outdated
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>
@AlexanderZ-Band

Copy link
Copy Markdown
Collaborator Author

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

This branch has not been deployed

No deployments
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.

2 participants