Files
Teknium d88b2ea44a fix(gateway): take post-delivery by the turn's own key; drop closed-session retirement from this PR
The drain took the post-delivery slot by live.route read in the turn's finally, so a rebind
mid-turn looked under the new key and stranded the callback the turn registered under its own.
The key is now captured when the turn starts.

Deleting a session forgets its local-adapter state only for LOCAL sources, so a runner without
an adapter map (non-local test doubles) no longer raises on delete/prune.

Retiring closed local sessions from authority.sessions (and the _schedule restore) moves to a
follow-up PR.
2026-11-01 22:05:45 -08:00

569 lines
32 KiB
Python

"""Trusted messaging admission and the existing TurnRunner invocation boundary."""
import asyncio
import logging
from contextvars import ContextVar
from contextlib import asynccontextmanager, nullcontext
from dataclasses import replace
from gateway.platforms.event import MessageEvent, ProcessingOutcome
from gateway.session_envelope import restore_native
from gateway.session_producer_lifecycle import close_producer_turn, producer_turn
from hermes_cli.observability.shared_metrics_busy import (
busy_turn_started, note_busy_admitted, note_busy_parked, settle_busy_arrival)
from hermes_state_runtime import RuntimeStoreError
logger = logging.getLogger(__name__)
admission_author = ContextVar('admission_author', default=None)
executing_admission = ContextVar('executing_admission', default=False)
# The live session while the executing turn is one its viewer submitted (``is_viewer_input``), else None.
viewer_turn: ContextVar = ContextVar('viewer_turn', default=None)
# Set by a busy-path caller that must be released once its input is durably queued.
busy_acceptance = ContextVar('busy_acceptance', default=None)
# Set beside it by a caller that must also know when the row is durably committed (a busy burst's
# commit task, which command barriers and /restart wait on): ``busy_acceptance`` may already resolve
# once the input's FIFO position is fixed behind an earlier input of its chat still capturing.
busy_commit = ContextVar('busy_commit', default=None)
# ``_validate_native`` clauses about the message's own content, safe to name to the sender's chat.
_CONTENT_CLAUSES = frozenset({
'text', 'auto_skill', 'channel_prompt', 'channel_context', 'reply_expected',
'media_urls', 'media_types', 'media_text_inlined', 'media_types_length', 'media_text_inlined_length',
'media_filenames', 'media_filenames_length',
})
# Nothing was committed (main's busy-queue cap); /retry answers it before rewriting (#139257).
QUEUE_FULL_REPLY = ('⏳ Too many messages are already waiting in this conversation; '
'send this again after the current reply.')
async def admit_message(authority, event):
from gateway.session_authority import messaging_admission
accepted, committed = busy_acceptance.get(), busy_commit.get()
busy_acceptance.set(None) # the drain task this schedules must not inherit the busy caller's future
busy_commit.set(None)
ingress = messaging_admission.set(True if accepted is None else accepted)
try:
receipt = await authority.admit_native(event)
if committed is not None and not committed.done():
committed.set_result(None) # the row is durable (any status): a barrier may go on
except RuntimeStoreError as exc:
if exc.reason == 'stale_generation' and exc.field == 'route':
return None # a later /stop or /new of the chat fenced it, as main cancels a queued row
if exc.reason == 'invalid_params' and exc.field:
# Refused before commit (_validate_native): a refusal notice instead of a generic turn error.
# The reply reaches the whole chat, so only content clauses are named; trust and provenance
# clauses (and any new one) stay generic, and gateway.log keeps the clause (``_refuse``).
named = f'{exc.reason}: {exc.field}' if exc.field in _CONTENT_CLAUSES else exc.reason
return (f'⚠️ Hermes refused this message ({named}), so it was not saved '
'or answered. Ask the operator to check gateway.log.')
if exc.reason != 'queue_full':
raise
# Nothing was committed (main's busy-queue cap): say so instead of a generic turn error.
return QUEUE_FULL_REPLY
finally:
messaging_admission.reset(ingress)
if receipt.status == 'terminal':
return None
# Only the delivery waiter is process-local; execution reads the committed snapshot.
authority.native_waiters.add(receipt.admission_id)
waiter = authority.waiters.setdefault(receipt.admission_id, asyncio.get_running_loop().create_future())
adopters = _marker_adopters(authority)
if getattr(event, '_turn_marker_handoff', False):
# The adapter lifecycle that sends this reply releases the turn's crash marker only once
# the reply is in the delivery ledger; the drain hands its marker to this event.
adopters[receipt.admission_id] = event
# A drain already running can claim, run and settle the row before this caller resumes; one
# that settled it already sent (or owes) the reply itself and resolves no waiter registered now.
from hermes_state_runtime import get_session_admission
row = get_session_admission(authority.db, admission_id=receipt.admission_id)
status = 'terminal' if row is None else row['status'] # None: deleted with its chat, nothing owed
note_busy_admitted(event, receipt.admission_id, status, None if row is None else row.get('outcome'))
if status in ('terminal', 'unknown'):
authority.native_waiters.discard(receipt.admission_id)
if authority.waiters.get(receipt.admission_id) is waiter:
del authority.waiters[receipt.admission_id]
if adopters.get(receipt.admission_id) is event:
del adopters[receipt.admission_id]
return None if status == 'terminal' else pause_notice(authority, receipt.ref, 'unknown_execution')
if accepted is not None and not accepted.done():
accepted.set_result(None)
try:
return await asyncio.shield(waiter)
except RuntimeStoreError as exc:
row = get_session_admission(authority.db, admission_id=receipt.admission_id)
if row is not None and row['status'] == 'terminal':
# The drain refused this head (``_refuse_head``): cancelled, not saved behind a pause.
return (f'⚠️ Hermes did not answer this message ({exc.reason}): the conversation it was '
'queued for moved, so it was dropped. Send it again to continue.')
if row is not None:
note_busy_parked(event) # still queued (saved): answered after the pause, not dropped
return pause_notice(authority, receipt.ref, exc.reason)
finally:
if adopters.get(receipt.admission_id) is event:
del adopters[receipt.admission_id]
def _marker_adopters(authority):
adopters = getattr(authority, 'marker_adopters', None)
if adopters is None:
adopters = authority.marker_adopters = {}
return adopters
_MARKER_FIELDS = ('_gateway_active_turn_session_key', '_gateway_active_turn_token')
async def _hand_over_turn_marker(authority, admission_id, event):
"""The executed turn's durable active-turn marker outlives the handler, as the in-process
turn's does on main, and is released only once its reply is in the delivery ledger (or nothing
is owed). A kill between the terminal commit and that ledger write then leaves a marked turn
whose persisted reply the next unclean boot ledgers and sends once, without new inference,
instead of a settled answer nobody owes. The waiting adapter lifecycle adopts it; a recovered
no-waiter reply keeps it until ``deliver_settled``; any other turn releases it here."""
adopter = _marker_adopters(authority).get(admission_id)
if adopter is not None:
_move_marker(event, adopter)
return
if admission_id in authority.pending_deliveries:
# Released by ``deliver_settled``, also when settlement failed and dropped the delivery.
_held_markers(authority)[admission_id] = event
return
await _release_turn_marker(authority, event)
def _move_marker(source, target):
for name in _MARKER_FIELDS:
if hasattr(source, name):
setattr(target, name, getattr(source, name))
delattr(source, name)
def _held_markers(authority):
held = getattr(authority, 'held_turn_markers', None)
if held is None:
held = authority.held_turn_markers = {}
return held
def _registry(authority, name):
found = getattr(authority, name, None)
if found is None:
found = {}
setattr(authority, name, found)
return found
def waiter_replies(authority):
"""admission -> the reply a live native waiter is about to send, kept only until the drain
settles the turn, so a turn fenced ``unknown`` can still owe it to the chat."""
return _registry(authority, 'waiter_replies')
def retain_unsettled_delivery(authority, admission_id):
"""Settlement could not commit and the turn is fenced ``unknown``, but this owner keeps its
captured answer for Discard (``resolve_unknown``). The reply stays owed to its destination,
and so does the turn's crash marker: the live waiter is released with a pause notice, so the
marker is taken back from it. Restart while unknown clears the marker (no answer is owed,
``release_unknown_turn_markers``); a Discard that commits the captured answer makes it owed,
sent once by ``deliver_resolved`` or, after a kill before that send, by the boot ledger."""
delivery = waiter_replies(authority).pop(admission_id, None)
if delivery is not None:
adopter = _marker_adopters(authority).get(admission_id)
if adopter is not None:
_move_marker(adopter, delivery[1])
_held_markers(authority)[admission_id] = delivery[1]
else:
delivery = authority.pending_deliveries.pop(admission_id, None)
if delivery is not None:
_registry(authority, 'unsettled_deliveries')[admission_id] = delivery
async def deliver_resolved(authority, admission_id, *, owed):
"""After a Discard committed: send the retained reply when its captured answer committed with
the resolution (``owed``), else release its marker and drop it. Popped first, so a repeated
resolution never sends twice; a turn the drain still owns is delivered by the drain."""
delivery = _registry(authority, 'unsettled_deliveries').pop(admission_id, None)
if delivery is None:
return
if owed:
authority.pending_deliveries[admission_id] = delivery
await deliver_settled(authority, admission_id)
def _take_post_delivery(authority, admission_id, source, route):
"""Move the turn's post-delivery slot off its adapter onto this admission. A native waiter (the
messaging adapter task that sends the reply) runs it after its own send instead. A runner or
adapter without the seam (a webhook sink, a bare test double) holds none."""
resolve = getattr(authority.runner, '_adapter_for_source', None)
native = admission_id in authority.native_waiters
slots = getattr(resolve(source), '_post_delivery_callbacks', None) if resolve and source and not native else None
entry = slots.pop(route, None) if slots is not None else None
if entry is not None:
_registry(authority, 'post_delivery')[admission_id] = (slots, route, entry)
def _hand_back_post_delivery(authority, admission_id):
"""A native waiter registered after the turn started: its adapter task sends the reply and then
runs the slot, which goes back unless a newer turn's already took it."""
taken = _registry(authority, 'post_delivery').pop(admission_id, None)
if taken is not None:
slots, route, entry = taken
slots.setdefault(route, entry)
async def fire_post_delivery(authority, admission_id):
"""Run a settled turn's post-delivery callbacks (background-review release, temp-bubble cleanup,
a deferred /goal or /loop verdict) after its reply was sent, as ``_process_message_background``
does after a messaging reply. Never run, they also pinned the whole turn (its agent, history
and result) in the adapter's slot until restart."""
if admission_id in authority.native_waiters:
_hand_back_post_delivery(authority, admission_id)
return
taken = _registry(authority, 'post_delivery').pop(admission_id, None)
if taken is None:
return
from gateway.platforms.base import _POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS, _split_post_delivery_entry
from gateway.platforms.base_post_delivery import run_post_delivery_callback
callback = _split_post_delivery_entry(taken[2])[1]
with await _execution_scope(authority, None): # off-turn: the owning profile's scope
await run_post_delivery_callback(callback, _POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS)
async def _release_turn_marker(authority, event):
if getattr(event, '_gateway_active_turn_token', None):
await authority.runner._clear_durable_active_turn(event)
def pause_notice(authority, ref, reason):
"""The committed input stays queued behind a paused FIFO (a turn lost across an owner
restart, or a head the preflight refused). Tell the platform user once per pause episode
through the ordinary reply path; later messages onto the same pause are admitted silently."""
live = authority.sessions[ref.session_id]
if live.pause_notified:
return None
live.pause_notified = True
if reason == 'runtime_draining':
# Transient: the row runs when the restarted owner drains it, so /reset would be wrong advice.
return '⏳ Hermes is restarting — your message is saved and will be answered when it is back.'
if reason == 'session_busy':
# Transient: queued behind work already running on this conversation (a worker execution).
return '⏳ Your message is saved and will be answered when the work running in this conversation finishes.'
if reason == 'unknown_execution':
# GW-10: the way out is in this chat. /discard acknowledges the lost turn (never replayed)
# through the same fenced resolve_unknown as Desktop/TUI, and the saved messages then run.
# Slack registers no native /discard (its slash cap): the umbrella `/hermes discard` reaches it.
slack = getattr(getattr(live.source, 'platform', None), 'value', None) == 'slack'
discard = '/hermes discard' if slack else '/discard'
return ('⏸️ This conversation is paused: a previous turn did not finish when Hermes restarted, '
f'and only you know whether to send it again. Your message is saved. Send {discard} to drop '
'the lost turn and continue (it is not re-run), or /reset to start a fresh conversation.')
cause = f'a queued message could not be re-authorized ({reason})'
return (f'⏸️ This conversation is paused: {cause}. Your message is saved but will not be answered here. '
'Send /reset to start a fresh conversation, or ask the operator to resume this one.')
def row_turn_author(policy, row):
"""Who wrote the admitted input, for memory attribution only: the producer's stamp when it
left one, else the forwarded peer bound into the session policy; never grants anything."""
payload = row['payload']
for key in ('local_automation_v1', 'api_turn_v1', 'cli_turn_v1'):
author = (payload.get(key) or {}).get('turn_author')
if author is not None:
return author
from gateway.session_a2a import forward_author
return forward_author(policy)
async def execute_admission(authority, ref, row):
busy_turn_started(row['admission_id'])
from gateway.session_completion_notes import note_if_unviewed
noted = await note_if_unviewed(authority, ref, row) # #134889 LC-7: no viewer, no turn
if noted is not None:
return noted
from gateway.session_policy import policy_for_source
policy = policy_for_source(authority.runner, authority.sessions[ref.session_id].source)
if policy is not None and policy.source == 'cron':
from gateway.session_cron import execute
return await execute(authority, ref, row, policy)
local_policy = policy
from gateway.session_managed_worker import managed_policy, admit_managed
policy = managed_policy(authority, ref)
if policy is not None:
return await admit_managed(authority, ref, row, policy)
live = authority.sessions[ref.session_id]
# The key the turn registers its post-delivery slot under; a rebind mid-turn must not move the take.
route = live.route
native = row['admission_id'] in authority.native_waiters
authority.native_waiters.discard(row['admission_id'])
if 'native_text_v1' in row['payload']:
event = restore_native(row['payload'], authority.runner)
else:
from gateway.session_ingress_media import restore_attachments
event = MessageEvent(text=row['payload']['text'], source=live.source,
message_id=row['admission_id'], **restore_attachments(row['payload']))
if 'local_automation_v1' in row['payload']:
from gateway.session_automation import restore_local_automation
event = restore_local_automation(authority, ref, row)
if event.internal and 'gateway_session_id' in event.metadata:
# Main's ownership proof (802fd5782f3): the session the pre-claim check just validated, so the
# turn's resolver never re-reads (and on a transient failure drops) an admitted completion.
event.metadata['gateway_proven_session_id'] = (
authority.physical_target(ref) if 'native_text_v1' in row['payload'] else event.metadata['gateway_session_id'])
provenance = row['payload'].get('native_text_v1', {}).get('provenance')
home = None
if provenance is not None:
from gateway.session_ingress_context import restore_provenance
home = restore_provenance(authority.runner, event.source, provenance)
from gateway.config import Platform
from gateway.session_api_turn import api_execution, prepare_api_execution
from gateway.session_results import execution_result
is_api = live.source.platform == Platform.API_SERVER
if not (event.internal or is_api or live.source.platform == Platform.LOCAL):
# The claim on a chat route (native or a viewer's submit): a /new before the turn resolves
# its session must not move it onto the fresh conversation (main's /new cancelled its task).
event._claimed_owner = (ref.session_id, row['generation'])
author = event.metadata.get('turn_author')
author_token = admission_author.set(author if author is not None else row_turn_author(local_policy, row))
from gateway.session_operator import is_viewer_input
viewer_token = viewer_turn.set(live if is_viewer_input(row) else None)
api_token = api_execution.set(None)
captured = {}
result_token = execution_result.set(captured)
token = executing_admission.set(True)
# This drain, not the handler's unwind, releases the turn's crash marker (see below).
event._turn_marker_handoff = True
handed = False
try:
async with _admission_scope(authority, home, row, event, captured):
if is_api:
# Committed media resolves under the owning profile's home, like admission did.
prepared = prepare_api_execution(authority, ref, row['payload'])
api_execution.set(prepared)
if isinstance(event.text, list):
# The durable transcript keeps the committed media references (the same
# ``[Image attached ...]`` hints native transports persist), not only the caption.
event.text = '\n'.join(part['text'] for part in prepared['content'] if part.get('type') == 'text')
event.allow_gateway_control = False
event.internal = True # trust comes from the private binding and preclaim, never client JSON
response = await authority.runner._handle_message(event)
result = captured.get('terminal_result', captured.get('result'))
if result is None:
# No TurnRunner result means the handler answered without executing the turn.
# A turn that never ran because it failed (agent initialization raised, history
# unreadable) recorded its own failure via ``record_unexecuted_failure`` on every
# surface; any other reply here is a deliberate notice. An API caller asked for
# work, so its receipt is a failure either way, never a completed apology; so did
# a finite (``chat -q`` / ``-z``) prompt, whose exit code is the only verdict a
# script reads, unless the prompt was a command and the reply is its answer.
result = {'final_response': response or '', 'messages': []}
if is_api or (row['payload'].get('finite') is True and not event.get_command()):
result = {'final_response': '', 'messages': [], 'failed': True, 'completed': False,
'error': response or 'The admitted turn failed.'}
# The drain commits this under the stream lock so no viewer reads `terminal`
# before the completion event exists in the replay ring.
authority.pending_results[row['admission_id']] = {
'result': result, 'usage': captured.get('usage', {}),
**({'session_usage': captured['session_usage']} if 'session_usage' in captured else {})}
if not is_api and response:
adapter = authority.runner._adapter_for_source(event.source)
if adapter is not None:
# A recovered turn with no live delivery waiter: the drain sends this only after
# it commits the terminal outcome (deliver_settled), so a failed settlement never
# leaves an externally answered admission started -> unknown. A live waiter
# sends its own; the record only outlives a settlement fenced unknown.
delivery = (adapter, event, live.route, response, home)
if native:
waiter_replies(authority)[row['admission_id']] = delivery
else:
authority.pending_deliveries[row['admission_id']] = delivery
handed = True
await _hand_over_turn_marker(authority, row['admission_id'], event)
return response
finally:
if not native:
# No adapter task awaits this turn to run its post-delivery callbacks: the drain runs them
# once the turn settled and its reply went out (``fire_post_delivery``).
_take_post_delivery(authority, row['admission_id'], event.source, route)
if not handed:
await _release_turn_marker(authority, event)
executing_admission.reset(token)
execution_result.reset(result_token)
api_execution.reset(api_token)
admission_author.reset(author_token)
viewer_turn.reset(viewer_token)
def _launch_body_if_launch_owner(authority):
"""A standalone owner's turn binds nothing, but the launch profile's is marked at entry: a second
profile switching this gateway to multiplexing mid-turn must not fail its later credential reads
closed. Any other owner (a hosted named profile) stays unmarked, never reading the launch env."""
from pathlib import Path
from agent.secret_scope import _is_process_home
owner = Path(str(authority.profile_id))
if not owner.is_absolute() or not _is_process_home(owner):
return nullcontext()
from tui_gateway.launch_profile_policy import launch_body
return launch_body()
@asynccontextmanager
async def _admission_scope(authority, home, row, event, captured):
"""The execution scope, inside the adapter lifecycle the executor owns for a producer-admitted
turn (``session_producer_lifecycle``; a no-op for every other admission)."""
async with producer_turn(authority, row, event, captured):
with await _execution_scope(authority, home):
yield
async def _execution_scope(authority, home):
"""Under multiplex, owner-side execution runs under the OWNING profile's home (agent build,
config, secrets, state.db), never the launch profile's ambient scope; native provenance
(``home``) refines it. A single-profile gateway keeps its ambient scope byte-for-byte.
Secret sources (external CLIs) hydrate off the loop, as main's per-message scope does (#99519)."""
multiplex = getattr(getattr(authority.runner, 'config', None), 'multiplex_profiles', False)
if home is None and not multiplex:
return _launch_body_if_launch_owner(authority)
from gateway.run import _load_profile_secret_scope, _profile_runtime_scope
def load():
from pathlib import Path
scope_home = home
if scope_home is None: # owner_scope's home; symbolic test ids keep the ambient scope
owner = Path(str(authority.profile_id))
scope_home = owner if owner.is_absolute() and owner.is_dir() else None
return scope_home, scope_home and _load_profile_secret_scope(scope_home)
scope_home, secrets = await asyncio.to_thread(load)
return nullcontext() if scope_home is None else _profile_runtime_scope(scope_home, secrets)
async def deliver_settled(authority, admission_id):
"""Send a recovered no-waiter turn's reply after its terminal outcome committed. A send
failure cannot rewrite that outcome; it is logged, and inference never runs again."""
held = _held_markers(authority).pop(admission_id, None)
delivery = authority.pending_deliveries.get(admission_id)
if delivery is not None and admission_id in authority.native_waiters:
# ``admit_message`` registered its delivery waiter after this turn started: the drain
# hands that waiter the reply and its adapter lifecycle sends it, so this send is not owed.
authority.native_waiters.discard(admission_id)
_hand_back_post_delivery(authority, admission_id)
authority.pending_deliveries.pop(admission_id)
adopter = _marker_adopters(authority).get(admission_id)
if held is not None and adopter is not None:
_move_marker(held, adopter)
held = None
delivery = None
if delivery is None:
if held is not None:
# Fenced unknown: no answer is owed, so no later boot may deliver one either.
await _release_turn_marker(authority, held)
# Settled with nothing (left) to send: its reply streamed, or it had none.
await close_producer_turn(authority, admission_id, ProcessingOutcome.SUCCESS)
return
adapter, event, session_key, response, home = delivery
delivered = False
try:
with await _execution_scope(authority, home):
delivered = await deliver_response(adapter, event, session_key, response)
except Exception:
logger.exception('Delivery of settled admission %s failed', admission_id)
finally:
# Held until the send returns: an empty map means every settled reply was handed over.
authority.pending_deliveries.pop(admission_id, None)
# The ledgered send already released the marker; an empty or failed send owes no more.
await _release_turn_marker(authority, event)
await close_producer_turn(
authority, admission_id, ProcessingOutcome.SUCCESS if delivered else ProcessingOutcome.FAILURE)
async def deliver_response(adapter, event, session_key, response):
"""Send a turn's reply; whether it was delivered (``_process_message_background``'s verdict:
nothing to send, or at least one send that succeeded)."""
from gateway.platforms.base import _thread_metadata_for_event, _mark_notify_metadata
text, ttl = adapter._unwrap_ephemeral(response)
if not text:
return True
extracted = await adapter._extract_response_content(text, event, session_key, is_ephemeral_response=ttl > 0)
metadata = _mark_notify_metadata(_thread_metadata_for_event(event))
results = []
if extracted.text_content:
await adapter._send_final_text(event, session_key, extracted.text_content,
metadata, ttl > 0, ttl, results.append)
await adapter._deliver_attachments(event, extracted, metadata, anything_sent=bool(results),
record_delivery=results.append)
return any(getattr(result, 'success', False) for result in results if result is not None)
async def dispatch_shared_busy(adapter, event, session_key, *, durable=False):
"""Serial receive loops (IRC, Signal) await this before reading their next frame, so return
once the follow-up is committed to the FIFO, or queued behind an earlier input of its chat still
capturing (its position is then fixed; acceptance still waits for the commit); a tracked
observer delivers its one reply. *durable*: return only once the row is committed (or the input
was refused, fenced or answered), never on the earlier position release: a busy burst's commit
task, which a command barrier (``/restart`` closing admission) waits on. Otherwise an input
released before its commit is held until it commits or its handler ends, and a serial reader's
next command waits for that too (``burst_gate``): a non-burst busy input (a second video, a voice
note, text outside queue mode) has no burst commit task to wait on."""
delivery_event = replace(event, source=replace(event.source))
accepted = asyncio.get_running_loop().create_future()
committed = asyncio.get_running_loop().create_future()
async def observe():
# Like the first message's lifecycle, this sender adopts the turn's crash marker and
# releases it only once the reply is ledgered (the marker moves onto ``event``).
event._turn_marker_handoff = getattr(adapter, 'gateway_runner', None) is not None
try:
response = await adapter._message_handler(event)
# The ledgered send releases the marker off the event it sends.
_move_marker(event, delivery_event)
delivery_event._turn_marker_handoff = event._turn_marker_handoff
await deliver_response(adapter, delivery_event, session_key, response)
except Exception as exc:
if not accepted.done():
raise # nothing was queued: the receive loop sees the failure as before
logger.error('[%s] Queued busy follow-up failed: %s', adapter.name, exc, exc_info=True)
await adapter._notify_turn_error(delivery_event, exc)
finally:
settle_busy_arrival(event)
# Clears the marker if no ledgered send did, then wakes the drain's bounded wait,
# which listens on the adopter (``event``).
await adapter._release_turn_marker(delivery_event)
await adapter._release_turn_marker(event)
token, commit_token = busy_acceptance.set(accepted), busy_commit.set(committed)
try:
task = asyncio.create_task(observe())
finally:
busy_acceptance.reset(token)
busy_commit.reset(commit_token)
adapter._background_tasks.add(task)
task.add_done_callback(adapter._background_tasks.discard)
try:
await asyncio.wait({accepted, task}, return_when=asyncio.FIRST_COMPLETED)
if durable and not task.done():
await asyncio.wait({committed, task}, return_when=asyncio.FIRST_COMPLETED)
except asyncio.CancelledError:
if not accepted.done():
task.cancel() # cancelled before commit: the input was never ACKed
raise
if task.done():
task.result() # refused, consumed or answered inline before any durable acceptance
elif not committed.done():
_hold_until_committed(adapter, committed, task)
def _hold_until_committed(adapter, committed, task):
"""Released before its commit: the reader reads on, but this input is not durable yet. A serial
reader's next command waits for its commit or its handler's end (``burst_gate``), so /restart never
closes admission, and /new never cancels the chat's rows, before it lands."""
settled = asyncio.get_running_loop().create_future()
def settle(_):
if not settled.done():
settled.set_result(None)
committed.add_done_callback(settle)
task.add_done_callback(settle)
held = adapter.__dict__.setdefault('_uncommitted_inputs', set())
held.add(settled)
settled.add_done_callback(held.discard)