Skip to content

fix: apply agent.control signals off the agent_control topic processor - #870

Merged
AlexanderZ-Band merged 5 commits into
mainfrom
fix/control-signals-off-the-topic-processor-INT-1808
Oct 20, 2026
Merged

AlexanderZ-Band merged 5 commits into
mainfrom
fix/control-signals-off-the-topic-processor-INT-1808

Conversation

@AlexanderZ-Band

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

Copy link
Copy Markdown
Collaborator

Why

BandLink awaited the runtime's agent.control handling inline on the agent_control topic processor. Topic callbacks run one at a time, and supersede shares that topic. An agent-wide STOP drains every room's turn, up to a second per room, one room after another, so a supersede arriving behind it waited until the STOP returned. Today the platform's 1001 close happens to end such a session anyway. Once a live agent reconnects after 1000/1001 (INT-1808, stacked on this PR), that delay lets the evicted agent reconnect and evict the newer session.

What

  • BandLink._on_control hands each signal to the on_control hook on its own task, chained after the previous one, so signals still apply in arrival order and a supersede is handled at once.
  • A failed signal is logged at ERROR with its correlation and room ids, and the next signal still applies.
  • disconnect() cancels every unfinished signal, the running one and any queued behind it, and logs each by mode and correlation id.
  • BandLink.on_control states the hook contract: signals apply one at a time, so a hook that never returns holds the later ones until disconnect().
  • One ControlHook alias for the control hook type.
  • band-bridge's AgentRunner.close() cancels play nudges after the link has disconnected, because the link applies control signals until then.
  • Docstrings and band-bridge comments that said control signals run "from the WebSocket receive task" now say what holds.
  • Tests:
    • lane order and failure isolation (tests/platform/test_link_control_lane.py);
    • disconnect cancelling queued signals and leaving applied ones alone (tests/platform/linkclose/test_disconnect_cancels_control.py);
    • a play nudge spawned while the link disconnects never outlives close() (tests/bridge/test_close_after_control.py);
    • a real-wire supersede handled while a control handler is held (tests/platform/linkclose/test_supersede_during_control.py);
    • real AgentRuntime scenarios: a supersede behind an agent-wide STOP whose adapter is slow to cancel, and an agent stop cancelling a control that is still applying (tests/scenarios/test_session_end_during_control.py).

Scope

band-sdk-python only, with no behavior change for a signal that applies promptly. A stall policy for a hook that never returns is out of scope, tracked in INT-1864. First PR of the INT-1808 stack: #869 (reconnect after 1000/1001) builds on it, and a test-structure PR follows.

Linear: INT-1808

🤖 Generated with Claude Code

https://claude.ai/code/session_01PBt2TLCc2YD1AXxmmo61Hx

@linear-code

linear-code Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

INT-1808

INT-1864

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

Blocking — would be REQUEST_CHANGES on a PR by someone else.

Cycle-1 review of #870 (control lane). Blocking:

  • a hung hook now stalls the lane permanently, and reconnect no longer recovers it (link.py:447, proved on a real wire against main);
  • a queued-signal cancel path no test covers.

Outside the diff: the PR makes four band-bridge comments wrong. They still say the control handler runs on the WebSocket receive task, and it now runs on BandLink's control lane:

  • band-bridge/bridge_core/control.py:43, which also names _seen_control_ids, now ControlDeduplicator;
  • band-bridge/bridge_core/control.py:79;
  • band-bridge/bridge_core/bridge.py:124;
  • band-bridge/bridge_core/bridge.py:592.
    The design behind them still holds: the lane is ordered, so an awaited play nudge would still hold up a later stop. Only the stated reason is wrong.

Comment thread src/band/platform/link.py
Comment thread src/band/platform/link.py Outdated
Comment thread src/band/platform/link.py Outdated
Comment thread tests/scenarios/test_session_end_during_control.py
Comment thread tests/scenarios/test_session_end_during_control.py
Comment thread tests/platform/test_link_control_lane.py
Comment thread tests/platform/test_link_control.py
Comment thread tests/platform/linkclose/kit.py
@AlexanderZ-Band

Copy link
Copy Markdown
Collaborator Author

Review-body item (stale band-bridge comments): fixed in a246b25. bridge_core/control.py (the dedup comment now names ControlDeduplicator, plus the play-nudge comment) and bridge_core/bridge.py (_control_tasks and _spawn_play_nudge) now say control signals arrive one at a time on BandLink's control lane, instead of on the WebSocket receive task.

@AlexanderZ-Band

Copy link
Copy Markdown
Collaborator Author

Cycle-2 review of #870 (head a246b25). Most findings were about the cycle-1 adapter deadline, so I stopped the cycle instead of adding a fix on top of that fix:

  • it didn't cover band-bridge hooks;
  • a hook that swallows the cancel defeated it;
  • an agent-wide signal could take N rooms × 5s;
  • scenario tests depended on wall time;
  • its expired() branch and WARNING level weren't covered;
  • the constant's meaning and the docstring wording didn't fit.

The deadline is reverted in be3a864 and deferred to INT-1864.

The remaining non-deadline findings:

  • The queue check was folded into another test (test_link_control.py): fixed in be3a864, test_on_control_does_not_enqueue is its own test again and now waits for the hook to run first.
  • test_disconnect_cancels_control.py calls link._on_control instead of server.push: not worth it. A pushed signal queued behind a held one gives no public barrier that it reached the lane. The push → _on_control wiring is covered by test_supersede_during_control.py and test_connect_wires_on_control_to_channel.
  • The applied-signal test depends on done-callback timing: not worth it. disconnect() always awaits the channel leave before cancelling the lane, and that is disconnect's own order. Removing the done callback still fails the test.
  • Hypotheses already settled in cycle 1 (a lane task escaping disconnect when the leave fails, teardown overlapping a STOP still applying, disconnect() cancelled mid-wait, a CancelledError raised inside the hook): unchanged and not re-raised.

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

Blocking — would be REQUEST_CHANGES on a PR by someone else.

Cycle-3 review of #870 (head be3a864).

issue (outside the diff's hunks): band-bridge/bridge_core/bridge.py:237. close() cancels _control_tasks before await self._link.disconnect(). With the control lane, a PLAY queued behind a running agent-scope STOP still applies during disconnect()'s agent_control leave round trip, while the STOP's _resolve_rooms REST fetch returns. That PLAY runs _spawn_play_nudge after the cancel loop. Nothing cancels the new _nudge_room task, so it calls /next and forwards to the forwarder close() has already closed. On main the PLAY sat in the topic queue behind the inline STOP and _handle_leave_mode dropped it once the leave was requested.

  • Flow: SIGTERM → BandBridge._shutdown → AgentRunner.close() → cancel loop (empty) → link.disconnect() → leave awaits its reply. The STOP's REST returns, the PLAY applies and spawns a nudge, then _forwarder.close() runs and the nudge forwards m1 to the closed forwarder.
  • Proof: /private/tmp/claude-501/-Users-zaikman-repo-band-sdk-python-copy/8bebec4c-0a5e-4e5a-9eb2-2741d4957c52/scratchpad/review870/skeptic3/h1_bridge_close_nudge.py, using a real AgentRunner, BandLink and PHX client against FakePhoenixServer, with only the REST boundary gated.
    • Head: control tasks alive after close(): 1 / forward m1 (forwarder closed=True).
    • Main: control tasks alive after close(): 0, with no /next.
  • Fix: cancel and await _control_tasks after the link is disconnected. The lane can't spawn a nudge once disconnect() has returned.

Settled items re-raised without new evidence were dropped: a lane task escaping disconnect when the leave fails mid-reconnect, the test that disconnect awaits cancelled tasks, and the direct _on_control calls in the disconnect tests. A SIGTERM-path variant was dropped: the signal path now matches the host-stop behaviour settled in cycle 1.

Comment thread src/band/platform/link.py Outdated
@AlexanderZ-Band

Copy link
Copy Markdown
Collaborator Author

Cycle-3 review-body item (band-bridge/bridge_core/bridge.py:237, a play nudge outliving close()): fixed in 44bfa5f. close() now cancels and awaits the nudge tasks after link.disconnect() returns, since the link applies control signals until then. Regression test: tests/bridge/test_close_after_control.py. It fails on the old order (the nudge is never cancelled) and passes now. The skeptic's real-wire repro now reports control tasks alive after close(): 0, with no forward.

AlexanderZ-Band and others added 5 commits October 20, 2026 05:54
BandLink awaited the on_control hook inline on the agent_control topic
processor. A STOP drains every room's turn and can take seconds, and a
supersede on the same topic queued behind it until the hook returned.
The hook now runs on an ordered task lane: each signal applies after the
one before it, a failed signal is logged at ERROR with its correlation
and room ids without blocking the next, and disconnect() cancels what is
still pending and logs how many signals it abandoned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBt2TLCc2YD1AXxmmo61Hx
test_link_control.py and test_link_control_lane.py each defined the same
`link` fixture; it now lives once in tests/platform/conftest.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBt2TLCc2YD1AXxmmo61Hx
…lane

Signals are applied one at a time, so an adapter on_interrupt that never
returns held back every later interrupt/stop/play until disconnect. The
adapter notification is now abandoned after BACKEND_CANCEL_TIMEOUT_SECONDS
with a WARNING naming the room and mode.

disconnect() names the control signals it cancels. Tests cover a signal
queued behind the running one at disconnect and the lane emptying once a
signal applies. The control hook type has one alias, and the band-bridge
comments state where control now runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBt2TLCc2YD1AXxmmo61Hx
The per-room deadline on the adapter's on_interrupt bounded only cooperative
hooks, left band-bridge hooks unbounded, and made scenario tests that hold
a control hook depend on wall time. BandLink.on_control now states the
contract instead: signals apply one at a time, so a hook that never returns
holds the later ones until disconnect(). A lane-level stall policy is
tracked separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBt2TLCc2YD1AXxmmo61Hx
BandLink applies control signals until disconnect() returns, so a PLAY
queued behind a running STOP could spawn a nudge after close() had already
cancelled the nudge tasks; that nudge then forwarded to a closed forwarder.
close() now cancels and awaits the nudges once the link has disconnected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBt2TLCc2YD1AXxmmo61Hx
@AlexanderZ-Band
AlexanderZ-Band force-pushed the fix/control-signals-off-the-topic-processor-INT-1808 branch from 44bfa5f to 74a015e Compare October 20, 2026 03:04
@AlexanderZ-Band
AlexanderZ-Band added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 23ea807 Oct 20, 2026
42 checks passed
@AlexanderZ-Band
AlexanderZ-Band deleted the fix/control-signals-off-the-topic-processor-INT-1808 branch October 20, 2026 03:48
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