Skip to content

feat: consume all queued messages in one turn - #801

Open
AlexanderZ-Band wants to merge 139 commits into
mainfrom
int-1770-sdk-option-to-consume-all-queued-messages-in-one-turn
Open

AlexanderZ-Band wants to merge 139 commits into
mainfrom
int-1770-sdk-option-to-consume-all-queued-messages-in-one-turn

Conversation

@AlexanderZ-Band

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

Copy link
Copy Markdown
Collaborator

Why

A room busy with one turn accumulates messages, and the SDK then runs each as its own model turn. The agent answers stale requests one by one instead of seeing the whole queue at once (INT-1770). Retaining terminal outcomes also had gaps: a turn cancelled before its pending write was stored, or a lost ACK, could re-run the model or lose the outcome.

What

  • SessionConfig.consume_all_queued (off by default) and max_queued_messages (1–100, default 10). This is an execution policy, not a Capability. Batch mode rejects a non-default retry count, hosts or adapters that cannot batch, and one-shot invocation, all at start.
  • One room-owned attempt carries an immutable tuple of original messages and one shared outcome. Admission is recorded only after every mark_processing is accepted and ownership is rechecked, so an earlier INTERRUPT or shutdown consumes nothing. This applies to singleton turns too.
  • The terminal intent (processed or failed) is stored synchronously before cleanup or ACK I/O, and recovery retries only that write, never the model. SettlementResult (ACCEPTED, UNCONFIRMED, NOT_FOUND, REJECTED) is exposed through additive mark_*_result methods; the boolean methods are unchanged. A proven 404 or a validated no-processing-attempt 422 parks the intent until reconnect. While a write is unconfirmed the room defers ordinary work, including after a failed turn.
  • A selector merges the ascending actionable page with retained WebSocket originals by (inserted_at, id) into a bounded prefix. A WebSocket-only original (a thread reply REST omits) runs alone at its position, and a non-message event ends the segment. Durable FAILED deliveries are never selected, and a page of skipped inputs continues the scan immediately.
  • Approval, status and question replies are recognised through a read-only is_control_message adapter hook and run alone under their own delivery and authorization, never inside a batch.
  • AgentInput.messages carries each original with its own sender; on_turn is the single model-input builder for singleton and batch turns. Current inputs render as one JSON record per original, so the Anthropic singleton prompt shape changed too. SDK-sent relays and notices mention each distinct sender once, and the ACP/OpenCode singular "Current requester" fields are gone. band_no_reply guidance covers the whole turn.
  • Batch support is declared by Anthropic, Gemini, Google ADK, LangGraph, Pydantic AI, Agno, Strands, CrewAI, Parlant, NOOA, Letta, Claude SDK, Codex, Copilot SDK, OpenCode and the ACP clients (generic, Copilot, OMP, Cursor). A2A, the A2A gateway, CrewAI Flow, the ACP server and the Slack wrapper stay off and refuse the option.
  • Cursor's provider slot is owned by the active CursorTurn; STOP/INTERRUPT/teardown cancel its task, and refused rooms wait in a first-come capacity line.
  • Docs: docs/consume-all-queued.md, docs/turn-outcome.md, docs/acp.md. Requires band-sdk-core 2.9.0.

Scope

Off by default; with it off, singleton turns behave as before apart from the shared admission ordering and the lifecycle corrections above. Delivery stays at-least-once: a crash between processing and the terminal write can repeat work, and failed inputs are never retried automatically in batch mode. Batching passes each original's existing content and adds no attachment hydration. Terminal intents are held in memory, so a process exit can lose them. TypeScript is INT-1783 and platform changes are out of scope.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT

AlexanderZ-Band and others added 11 commits October 13, 2026 20:11
Batching is an execution policy, so SessionConfig owns it: off by default,
with a ceiling of 10 messages per turn validated from 1 to the platform's
page size. Enabling it with a non-default model retry count is a
configuration error rather than a silent override. The page size and retry
default each get one named definition in runtime/types.py.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
The prompts now embed band-sdk-core's band_no_reply description, which
asks the model to consider every current input together and decline only
when the turn as a whole needs no answer. SendMessageInput no longer
restates a latest-message decline rule, and the turn-completion guidance
refers to the current inputs. A conformance test pins the canonical text
on each prompt surface and the advertised tool description.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
…ycle

mark_processed_result and mark_failed_result perform the single logical
processed/failed write and classify it as ACCEPTED, UNCONFIRMED, NOT_FOUND
or REJECTED. mark_processed and mark_failed become explicit-ACCEPTED
projections with unchanged bool, STOP and cancellation behavior.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
…ent owner

The live, backlog and control paths each repeated claim, admission, run and
acknowledgement bookkeeping. A single attempt runner now owns it for a tuple
of originals carried on the TurnScope: claims are taken together, every
original is marked processing in order, and the scope is admitted only after
the last acceptance, so a control that lands earlier consumes nothing and
leaves the messages recoverable. The terminal write each original owes is
stored in the claim registry at the synchronous point where the handler's
outcome is known, before any cleanup or acknowledgement await, and is written
from that stored intent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
…ion-to-consume-all-queued-messages-in-one-turn
Terminal acknowledgement recovery now reads the intent stored in the claim
registry and dispatches on it: a processed intent writes processed, a failed
intent writes failed with its stored error, and neither is ever inferred from
the other. The runtime branches on the detailed settlement result. An
accepted write completes the original; a proven 404 or a validated
no-processing-attempt 422 parks the unchanged intent so it stops blocking
unrelated work without being replayed; an unconfirmed write keeps its intent
however many retries pass, and ordinary execution in that room waits behind
it. A reconnect or rejoin unparks refused writes so each is retried once. A
recovery pass attempts every pending original, so one bad write cannot starve
its siblings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
… STOP

Cursor binds session-less notifications to the active turn, so one provider
turn runs per adapter. The waiting turn lock queued a second room's input
behind the first and let a detached turn outlive STOP, retirement or
shutdown.

The active CursorTurn now owns the slot: a synchronous check-and-reserve
refuses an ordinary input with InputNotAccepted while it is held. The turn
owns its provider task; STOP, INTERRUPT, room retirement and adapter
shutdown cancel it through ACP's CancelledError path, once. The slot is
released only when the task actually terminates, including cancellation
before its first step, and a stale turn never unbinds or cancels its
successor. The ACP client adapter's own error and timeout paths use a
non-joining _teardown_room, so a turn's cleanup cannot wait on itself.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
A turn can now carry several original messages. AgentInput keeps each
original with its own sender, and a singleton turn is the default. Adapters
opt in through supports_batch_input and on_messages; consume_all_queued
refuses, at start, an adapter or preprocessor that cannot run a batch, and
one-shot invocation refuses it outright. The terminal-write settlement result
moves to band.core.settlement so the lifecycle and runtime share it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
…ion-to-consume-all-queued-messages-in-one-turn
With consume_all_queued the room merges the ascending actionable page with
the originals its WebSocket delivered into one bounded, chronological prefix
and runs it as a single attempt. A retained original the list omits (a thread
reply) runs alone once the scan reaches its position, a local non-message
event ends the segment, durable FAILED deliveries are never selected, and a
page of skipped inputs continues the scan without waiting for the periodic
resync. Refused or stopped originals now keep their payload in both modes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
The release carries the terminal-intent retention and parked-ack APIs the
room runtime uses. Settlement results map to core's ParkedReason explicitly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
@linear-code

linear-code Bot commented Oct 14, 2026

Copy link
Copy Markdown

INT-1770

AlexanderZ-Band and others added 18 commits October 14, 2026 09:41
Adds the optional CapacityWaitingAdapter contract. Cursor keeps one FIFO of
per-room capacity requests tied to a room generation; only the first waiter
may take a free provider slot, and withdrawal is identity-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
Add the user guide for consume_all_queued and extend the lifecycle docs with
admission order, stored terminal intents and settlement results, queued-message
selection and event ordering, the actionable-page read, delivery_status
preservation through the pinned REST client, and Cursor's provider-slot
ownership and cancellation. Doc snippets now run their coroutines for real.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
A message the adapter would handle as a control is recognized read-only, before
it is claimed or marked processing, so an ordinary candidate probed during
recovery receives no write. Recovery scans the deferred head and retained
WebSocket originals as well as the REST page, and each candidate is classified
after the previous control ran. In batch mode a recognized control ends the
turn before it and runs alone; a control the handler then declines runs as an
ordinary input. An adapter that overrides the handler without a recognizer is
still probed for every message.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
…ion-to-consume-all-queued-messages-in-one-turn
…ion-to-consume-all-queued-messages-in-one-turn
History for a batch stopped before the first original only when that
original was already stored; a later original leaked into history when the
first was not, then arrived again as a current input. Drop every original
from history, and share the room-context fixture the batching suites use.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
SimpleAdapter gains on_turn (the single model-input builder an adapter
overrides), judged_turn (the one outcome judge, now behind run_judged_turn
and on_messages alike) and convert_history. on_messages runs a judged turn
over the originals once an adapter declares supports_batch_input.

format_current_inputs renders each original as its own JSON record with
message id, sender id/name/type and timestamp, separated from history. The
Anthropic adapter is the reference: on_message delegates to on_turn and it
declares batch support, proven by a probe-driven conformance suite with a
drift guard for declaring adapters.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
A room whose ordinary input is refused only for shared adapter capacity keeps
one request in the adapter's line, tagged with the scheduler's own generation.
Admission failure, terminal-write backpressure, a claim held elsewhere, STOP,
shutdown and a cleared deferred head withdraw it; a replacement room's request
can never be cancelled by the room it replaced.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
…ion-to-consume-all-queued-messages-in-one-turn
The baseline suite rejects any collected test that requests a fixture named
peer without its adapter marker, which failed bare collection in CI. The
settlement peer fixture is renamed, and the new guide is formatted.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
LangGraphAdapter routes on_message and the batch entry point through one
on_turn builder that renders every current input with format_current_inputs
and runs the graph once. Batch support is proven by a probe that drives a
compiled graph and reads back the state it received.

The two bootstrap tests that asserted the legacy single-message prompt now
expect the current-input rendering.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
PydanticAIAdapter routes on_message and the batch entry point through one
on_turn builder that hands the agent a single prompt rendered by
format_current_inputs. Batch support is proven by a probe that runs the real
agent on a scripted FunctionModel and reads back what the model received.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
AgnoAdapter routes on_message and the batch entry point through one on_turn
builder whose run input ends with a single user message rendered by
format_current_inputs. Batch support is proven by a probe that runs a real
Agno agent on a recording model.

The Agno tests that asserted the legacy single-message prompt now expect the
current-input rendering, and the private run helpers take the turn's message
ids and originals instead of one message.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
StrandsAdapter routes on_message and the batch entry point through one
on_turn builder whose prompt is rendered by format_current_inputs, with the
participant and contact updates still folded into the same user message so
Bedrock's alternating-role rule holds. Batch support is proven by a probe
that runs a real Strands agent on a recording model.

The Strands tests that asserted the legacy single-message prompt now expect
the current-input rendering.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
on_message becomes a turn of one through on_turn, which renders the current
inputs with format_current_inputs and runs one function-calling loop, so a
singleton and a batch share it. The adapter declares supports_batch_input,
proven by a registered probe that reads the conversation the real adapter
sends to generate_content. A later-turn test pins that earlier originals stay
in the room's conversation once and in order.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
on_message becomes a turn of one through on_turn, which renders the current
inputs with format_current_inputs into ADK's single user message after the
replayed transcript. After the turn each original joins the room transcript as
its own entry, in the converter's own `[name]: content` shape, so a later turn
replays every member once and in order. The adapter declares
supports_batch_input, proven by a registered probe that reads the request
ADK's real runner hands its model.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
on_message becomes a turn of one through on_turn, which hands the room agent
one respond call whose message argument is the current inputs rendered with
format_current_inputs. The respond docstring the model reads no longer says
there is exactly one message. The adapter declares supports_batch_input, proven
by a registered probe that reads what NOOA's real agent loop sends its model.

Existing NOOA tests asserted the old singleton `[Alice]: text` line; they now
read the current inputs the model was shown, which also pins their order.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
…nly control recognition

OpenCode now builds one prompt for a singleton and a batch through on_turn and
declares supports_batch_input, proven by a batch probe over the prompt actually
submitted to opencode serve. Each current input is rendered with its own sender,
the per-turn system prompt drops the single requester fields, and approval
notices go to the distinct senders of the admitted input for the life of the turn.

is_control_message reads pending asks through the same route the control
handler uses, so a pending question makes free text an answer and a pending
approval recognizes only its own vocabulary, without creating room state,
claiming an ask or stopping its expiry timer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
AlexanderZ-Band and others added 18 commits October 14, 2026 19:16
INTERRUPT bumps the control revision without stopping the room, and the drain
read every revision change as the end of the pass. An attempt the INTERRUPT
consumed, or an INTERRUPT landing during a page read, therefore stranded the
work queued behind it until the 60 to 120 second idle resync, where singleton
mode runs the next event at once. A consumed attempt now continues the drain,
and a stale page is read again while the room still runs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
A control that was consumed, interrupted or had failed for good was returned as
deferred, which made the settled delivery the deferred head. Input arriving
afterwards then only ran control scans and waited for the idle resync, and the
head kept competing for shared capacity. Consumed heads were never cleared
either. A settled control now releases its payload, clears the head and its
capacity request, a deferred one withdraws its capacity request, and a scan
that cleared the head drains the input retained behind it in both modes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
…ngleton mode

Singleton mode retained an original only when its attempt was deferred. A STOP
mid-turn, before the model ran, or an arrival while the room is stopped left the
original unretained, so after PLAY neither the list nor /next could bring back a
thread reply and it stayed processing for good. Every non-advancing outcome now
keeps the payload, as batching already does; PLAY finds it through the retained
line.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
…trol leaves the line

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
…o-consume-all-queued-messages-in-one-turn

Main split runtime/types.py into owner modules and made SessionConfig a
settings class. The batching settings move onto that class: the ceiling is a
bounded field that rejects a bool, and the retry rule is a model validator.
A numeric string or integral float now coerces like every other setting,
which keeps the ceiling configurable from the environment.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
Three tests and the decision-audience suite typed '/cursor select <token>
allow-1' and '/cursor decisions' inline although select_command and
ROOM_COMMAND already own that wire text. Route them through the kit so a
change to the command vocabulary has one place to land.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
The package docstring claimed every framework import stays inside its probe
function, but the Anthropic probe imports at module top because every lane
venv installs it. State the actual rule: only a framework some lanes lack is
deferred.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
…ared turn input

The batching kit only imported AGENT_ID and ROOM_ID from tests/runtime/helpers,
yet twelve suites reached them through the kit. Each now imports them from
helpers directly, so the kit is no longer a second path to the same values.
The agent-input test drops its private AgentInput builder for the invocation
kit's turn_input, rebuilt without originals through dataclasses.replace.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
A refused input stays at the head of the line. When handling a later control
changes what that head classifies as, the next arrival must offer the head to
control handling again, even when only the room's retained original carries it
because the platform list omits it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
…edicates

Both predicates already check for a MessageEvent, yet four callers repeated the
isinstance test only to narrow the type. Typing them as TypeGuard[MessageEvent]
lets the callers rely on the predicate alone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
…essage

The backlog path checked a message's processed status before claiming it, and the
attempt then checked it again. The attempt's check also reads the hydrated
context, so it is the single owner. A message another claim holds now defers
instead of completing, as it did before batching.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
None carried 'another pass is due' across the batch step, boundary, empty-scan and
selection returns, so a stray return None silently continued the drain. ResyncOutcome
now has a PROGRESS member the drain loops on, and those steps can no longer return None.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
A repeated selection, a retained original released as skipped and a head deferred
behind a held claim each ended a pass silently, so a reply that never came could not
be explained from the logs. Each now logs its message ids and disposition once at DEBUG.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
The refusal was caught without its exception, so a host that is busy and one that
reserved its slot for a room waiting longer looked identical in the logs. The
not-accepted line now carries the refusal's message.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
Selection already used band.core.timestamps.assume_utc; the attachment
expiry check, Letta's time-ago formatter, the desktop room-view cursor and
message timestamp, and both CrewAI flow timestamp coercions each retyped the
same "naive means UTC" one-liner. They now call the helper.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
Removing the UTC normalization from MessageKey.of_message passed every
batching test, because the only naive fixtures went through of_event. The
REST message path is the one the singleton drain compares against retained
WebSocket originals.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
DefaultPreprocessor and the ACP client adapter each cut history at the first
original and then filtered the turn's other originals out of the prefix,
because messages_before took a single id. It now takes the turn's ids and cuts
at the first one stored, so neither caller filters and format_history_for_llm
keeps its single-id exclusion for the runtime callers.

When the first original is not stored yet but a later one is, entries after
that later original are now excluded as well, as they already were when the
first is stored: they are pending turns of their own.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
AlexanderZ-Band and others added 11 commits October 14, 2026 22:43
A host that refuses an input leaves it pending, and the room's next
periodic pass offers it again; the new test flips the host to accepting
after the first refusal and expects the turn at the next interval. The
batching host now keeps each refused offer so a test can wait for the
refusal instead of for a side effect of it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
No adapter implements CapacityWaitingAdapter since Cursor binds one provider
process per room, so the optional protocol, the scheduler's register/withdraw
calls, the per-incarnation generation counter and the capacity parameter
threaded through Agent, PlatformRuntime and AgentRuntime served nothing.
A refused input still stays pending and is offered again on the next arrival,
periodic pass or control, exactly as before; the register/withdraw calls only
informed the adapter and never scheduled anything.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
tests/runtime/test_deferral.py held ten module-level test functions; each
concern now lives in its own `Test...` class under tests/runtime/deferral/,
with the shared approval adapter and wire envelope in kit.py. The 15 test
cases are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
The live case of the approval-beyond-first-page test only asserted
`paged or not must_page`, which accepts a live approval that pages. Asserting
`paged is must_page` failed about one run in fifteen: with a 10 ms periodic
wake, a due wake scanned the room before the approval was taken from the queue
(or before B was retried), left the scan cursor at the end of page one, and the
approval's own scan then paged.

The live case now has no periodic wake and queues the scheduler pass that
retries B right behind the approval, ahead of ordinary traffic, so the first
scan reads the first page and the retained approval releases B. The recovery
cases keep the periodic wake and must page. The assertion is now exact.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
…ked and run once

An INTERRUPT that lands while a room hydrates, before any original is admitted,
must write nothing for the attempt, keep a WebSocket-only original in memory
and refund the charge, so the next pass marks and runs each original exactly
once. Covers the batching and singleton shapes, listed and WebSocket-only.

The admission kit gains the /next and actionable-page reads as scriptable
holds and one shared definition of the queued pair and the singleton shapes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
INTERRUPT bumps the control revision without stopping the room, and the
recovery pass still read every revision change as the end of the pass. An
INTERRUPT landing during the startup or recovery /next read or during a
retried terminal write, or one that consumed a singleton attempt, stranded the
queue behind it until the 60 to 120 second idle resync. A STOP still ends the
pass.

The same guard also skipped the deferred head of a superseded singleton
attempt, so an original deferred by a pre-admission INTERRUPT could be
overtaken by a later live arrival. A deferred attempt now keeps its place at
the head.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
A room whose host also handles control messages scans every arrival for
them, which the parking harness could not exercise. The harness can now
serve such a host, start a room under an outage of every terminal write,
and list the terminal writes the platform received more than once.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
…ion interval

Every entrance that meets a pending terminal intent retried it at once, so
during an outage each arrival cost one write per pending original: the
control scan wrote them per arrival and again inside every reconciliation
pass, and a deferred live arrival with no deferred head ran a full
reconciliation per arrival, growing with the number of pending intents.

An unconfirmed write now waits out idle_resync_seconds before any entrance
retries it. A due reconciliation and a requested resync (reconnect, room
start, PLAY) lift the wait and retry every pending write once, so recovery
needs no arrival and a reconnect still retries at once. The control probe
and a live duplicate retry only after the interval, and otherwise leave the
write to the next pass. Parked intents, settlement results and the stored
intent are unchanged; no model retry is added.

Tests that pinned an immediate entrance retry now wait the interval or
assert the paced behavior.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
The retry now waits a full reconciliation interval, so the test advances sixty
virtual seconds. Polling every 10 ms there never advances the virtual clock on
Windows with Python 3.12 or older, where a timer inside the 15.6 ms clock
resolution fires without a jump, and the loop spins until the test timeout.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
A control's scope is admitted before its processing write, so an INTERRUPT
that lands afterwards consumes it, which holds while its write or handler is
pending. During the hydration between the two, the probe instead read any
revision change as a deferral and left the control in processing, to be
handled again later. Only a change no control caused still defers; an
INTERRUPT is settled by the cycle's own unwinding and a STOP leaves the
control recoverable.

The admission kit can now serve a host that recognizes and handles controls.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>
…l write

The room keeps ordinary work deferred while a terminal write is unconfirmed,
which every path does by making the attempt's first original the deferred head.
The live path with batching off never did: a delivered original whose write
stayed unconfirmed returned to the loop without a head, so each further
arrival ran the model again during an outage. The attempt that finds its write
pending now sets the head itself, as a refused input already does.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FiE99Cx5avJ8qa2aXE6onT
Signed-off-by: Alexander Zaikman <alexander.zaikman@band.ai>

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.

1 participant