approvals.mode and yolo with scope=global were already refused when the
managed scope pins approvals.mode (set_config_value refuses it), but the
owner reported storage_unavailable. It now reports permission_denied with
field=approvals.mode, like the other owner config.set refusals. Nothing is
written either way, and any other failure keeps storage_unavailable.
config.set reasoning <level> with scope=global saved agent.reasoning_effort
through _save_gateway_config_key, which has no managed check, so a pinned
effort was written and the call answered success. It is now refused with
permission_denied naming the key before the session's level is committed
or anything is saved, as the sidecar's config.set already refuses it.
Display words, reset and session scope are unchanged.
The managed-key check re-ran the edit on a copy with the pinned leaves
removed to find out what it writes, so every edit had to be safe to
repeat (focus reads existing values to decide what to write). Each edit
now returns the dotted leaves it sets or clears along with its result;
_write_profile_config runs it once, refuses when one of those leaves is
pinned and only then saves. Setting a pinned leaf to the value the raw
file already holds is still refused; focus status declares no leaf.
A global /model pick with --reasoning also writes agent.reasoning_effort. When only that key was
administrator-managed, the immediate path switched the live model, pinned the session override and
persisted the model before refusing the effort (4030 after a partial success), and the mid-turn
path queued the pick. Both paths now check the effort key with the model keys, before any change.
The shared owner's config.set compared only leaves whose raw value changed, so setting a managed
skin to the value the raw file already held answered success while the managed value still won.
It now refuses any write that targets a managed leaf; read-only requests are unaffected.
The shared owner serves the TUI's config.set from
gateway/session_tui_config.py, which wrote the owning profile's
config.yaml with no managed-key check: `config.set skin mono` with
display.skin pinned saved the value and answered success while every
read kept the administrator's value.
_write_profile_config now compares the edit against the managed scope's
pinned leaves (managed_scope.managed_config_keys, the set
is_key_managed reads) and refuses a change to any of them as
permission_denied naming the key, before anything is written. The
personality write, which goes through persist_personality instead, gets
the same check. approvals.mode already goes through set_config_value,
which refuses managed keys.
Tests cover both TUI paths: a pinned key is refused with nothing
written, an unpinned key still writes.
gateway/builtin_hooks/ held only a docstring, and
HookRegistry._register_builtin_hooks() was an empty method called at the
start of discover_and_load(). Nothing registered through either one, so
hook loading is unchanged: user hooks under the profile's hooks/ dir
load exactly as before.
Remove both, the test helper that patched the empty method out, and
the developer docs (and zh-Hans copies) that listed the package as an
extension point.
Related: Code Health PLG-08.
Add hermes_cli.plugin_isolation.import_user_code(path, module_name): the
spec -> sys.modules -> exec -> pop-on-failure steps for loading one file of
user code, returning None when the file has no module spec. The gateway
HOOK.yaml loader now uses it instead of its own copy of those steps.
No behavior change: hooks load exactly as before under every
plugins.isolation mode, the 'could not load handler.py' skip line and the
'Error loading hook' path are unchanged. Keeping user hooks out of the
gateway process under host isolation is a separate, opt-in change.
Keep explicit Stop retries behind their original admission fence. Mint a
fresh identity when callers omit one, and drain every stoppable task on
Disband retries before allowing deletion.
(cherry picked from commit 92bed47fd5b8b6a8ce7071d67fb75515741a3139)
Mark only refusals that prove no message was admitted.
Keep committed receipts and preparation recovery authoritative.
(cherry picked from commit 63f6f197f995cc0344c3d26d375eae1e17721cc0)
A --safe-mode or --ignore-user-config chat got an empty auxiliary section, so the built-in per-task defaults (vision's 120s timeout, goal_judge's 60s, voice_chat's reasoning setting) fell back to the 30s client default. Its side tasks now use the session's own saved section, which holds code defaults only, so the profile is still never read. The bypass test asserts the vision timeout inside a bypass policy_scope.
Reported-by: unsupportedpastels
The local-turn .env reload ran for --ignore-user-config and --safe-mode sessions too, so a bypass chat read the profile file it promises not to read, and an unreadable .env could fail its turn. The reload now skips bypass sessions; the turn-runner test covers both kinds.
Reported-by: greptile-apps[bot]
A messaging turn reloads .env before it runs (inside _current_max_iterations); a local session's turn took its saved turn budget instead and skipped that reload with it, so a key rotated in .env reached it only after a gateway restart. Side tasks now read config.yaml, whose ${VAR} references expand against the environment, so a local turn runs the same reload. Its turn budget stays the session's own.
A local session's compression, vision and title routes came from the copy of `auxiliary:` saved when the chat was created, while background review, /btw, /review, the curator, vision's model and every `providers:` entry the routes name were read from config.yaml. A retired side-task model therefore kept failing in every older chat, and a saved key could reach an entry's new URL. Side tasks now read the profile's config.yaml in every in-process chat, as messaging chats already do. The chat's main model and route keep the saved config, --ignore-user-config / safe-mode sessions still see no profile at all, and managed workers keep their session snapshot as a whole.
This replaces the earlier rule that a changed side-task key sends the task back to the main model; the recovery test now asserts the new key reaches the next call.
The automatic-selection case is adapted from Gille's reset-compression regression tests in #147679.
Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
The resume refresh read config.yaml with the gateway process environment, so a ${VAR} that the profile's .env sets differently produced a changed value and a needless new generation on every attach. It now reads config.yaml the way session creation does.
The authority's roster named every profile on disk (#140417) but built those
discovered rows from a placeholder: description '', display_name set to the
handle, and neither has_avatar nor ui_meta_revisions. Desktop reads the row
for presentation — displayName() only title-cases the handle while
display_name is empty, the Edit profile dialog seeds its description field
from bot.description, and pushLocalAvatars() re-uploads a drawn image
whenever has_avatar is missing.
Send the profile's own description and display_name from its profile.yaml
(empty when it has none), and carry has_avatar and ui_meta_revisions for
every row. The two derived fields move into helpers shared with the active
profile's row so both shapes agree, and an unreadable profile.yaml still
degrades to a basic row.
(cherry picked from commit 4a0913c3fc746d5072b6abf2799ffe2a59570cfa)
(cherry picked from commit 5802ba607ab50f68cde4afe95a0a4b757fb9b0e3)
The shared gateway's profiles.list returned the launch profile's description but
hard-coded '' for every sibling profile it discovers. Desktop seeds Edit profile,
the avatar prompt, role search and Duplicate from that roster, so a saved
description on any non-default bot looked like it never saved (the file had it;
`hermes profile describe <bot>` read it back). Read it from the profile.yaml the
row already parses for display_name.
Reported on Discord (0.22.7, "bot descriptions are not saving"); diagnosed by
Gille in #149066, whose editor pre-read this replaces at the roster.
Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
An outside adapter following the guide kept the answer-the-oldest bug, and one using the new
helper drew a private-import warning at admission. Review follow-up.
Discord already binds its approval view to the tools.approval request id
(4f86e97791, 50f57a131f) and the runner passes ExecApprovalPrompt.request_id
since cfed2ca71f. Every other native-button surface still resolved by
session key alone, FIFO: when the request a card was rendered for had
already settled (timeout, typed /approve), a press on that card resolved
the NEXT pending approval in the session, one the user had not seen.
Telegram, Slack, Feishu, Matrix, WhatsApp Cloud, Teams and the relay
adapter now remember prompt.request_id with the card and resolve through
tools.approval.resolve_gateway_approval_card, which targets only that
request. A card sent without an id keeps the FIFO behaviour.
Review finding: every gateway exit ends in os._exit via
_exit_after_graceful_shutdown, which skips the atexit writer, so
`hermes --cpu-profile p.prof gateway run` profiled the whole life and
wrote nothing. Call write_cpu_profile as a best-effort step before the
log drain (no-op when profiling is off). Net-zero lines in run.py.
Review finding (P2, _resolve_container_fallback): raising the shared ingest
cap to 250 MiB also raised the sandbox exec-read, which returns the file as
base64 on stdout and peaks near 4x the file in this process (200 MiB file ->
+800 MiB RSS). _MAX_INGEST_BYTES is back to main's 50 MiB for the sandbox and
data: paths; host-file reads (bounded during the read) use the new
_MAX_HOST_READ_BYTES = 250 MiB, which _finalize also uses as the ceiling.
Also fixes the gateway/media_fetch.py comment (P3) that claimed its 50 MB
fetch cap mirrors the ingest cap.
Feishu already rendered exec approvals and update prompts as interactive
cards, but the clarify tool fell back to the base numbered-text prompt and
finite-choice slash commands (/reasoning, /fast, usage-limit switch) fell
back to text status cards. This implements the generic adapter contracts
from BasePlatformAdapter instead of a Feishu-only path:
- send_clarify(): multiple-choice questions become a card with one numbered
button per option plus "Other (type answer)"; a tap answers through
resolve_gateway_clarify and the callback response settles the card inline
for every client; "Other" calls mark_awaiting_text. Open-ended questions
use the base text prompt. Answers come from the pending clarify entry, so
a tap after a timeout or /new reports the prompt expired.
- retire_clarify_card(): the gateway's hook for a clarify that ended without
a tap (timeout, reset, superseding prose, typed answer) PATCHes the card
button-less; the card is a shared card (update_multi) so the update
reaches every recipient.
- send_choice_picker(): one-tap picker card; the tap settles the card and
runs on_choice_selected on the adapter loop, then sends its confirmation.
- Every click passes the approval path's _validate_card_action (operator
authorization incl. the gateway pairing check, callback-vs-card chat
match); state pops are first-writer-wins, so a double or stale tap is
refused.
Behaviour lives in plugins/platforms/feishu/adapter_choice_cards.py (mixin);
adapter.py stays at main's length (card-action routing became a table).
New platform.feishu.clarify/picker strings in all 17 locales, reusing each
locale's existing Telegram/Discord wording.
Review round on #148903 (Greptile 4467193888..4467194077):
- Bot Mode gate (agent/bot_chain.py, tools/bot_mode_dm_chain.py) runs once per
message_agent call before any transport, on #144789's code. Recipients are keyed
by the author id their turns carry (bot:default for @hermes, bot:<conn>/<profile>
for relay rows, the peer's bot:<host>/<profile> or peer:<name>/<agent>).
- Every call inside a bot-authored turn is one more hop; a person's turn sends at 1.
- A definite no-send (offline relay target, refused admission) releases its ask
and hop; queued/ambiguous keep them.
- A reply to the turn's sender reuses #144789's notify-only path (marker +
wake="none"); the receiver reads the marker from the text, so it survives the
Desktop relay, which rebuilds the author. The parallel expects_reply author field
is gone.
- Forks (_turn_origin) neither reset nor advance the chain; the chain is bound
after compression recovery and follows a session id change (rotation carry and
per-agent rebinding).
- gateway.bot_loop_guard.enabled: false switches the Bot Mode rules off too.
- Gateway: gateway/bot_peer_notices.automatic_notices_allowed is the one policy
at every runtime-notice egress (platform notices incl. delegation failure,
inactivity warning, hygiene, background failure, approval timeout, command
acks, context-ref refusals, STT echo, status/progress lanes).
- A bot may end in silence even when it addressed us (reply_expected=True).
- reply_to_reply keys on a reply this bot DELIVERED (note_reply), not on admission.
- Tests split by owner: tests/agent/test_bot_chain.py, tests/tools/test_bot_mode_dm_chain.py,
gateway ingress stays; config keys covered through the real config.yaml loader.
Each finding's test fails with its guard removed (mutant run).
The session authority re-enters _handle_message to run a committed row, so the
chain check saw its own hash and dropped every bot turn as a duplicate (found by
the live Telegram stand-in leg). Skip it under executing_admission.
Invariant tests (red on 71736c9f: 6 failed + chain module missing; human
controls pass) for every runtime-notice egress and both chain lanes, config
defaults for max_hops / bot_replies_expect_reply, and the user docs.
Widens #137037 from Mattermost to the whole class (#137027):
- Runtime notices never reach a bot peer: every busy-path reply (ack, redirect,
drain), lifecycle/status and credits notices, tool progress, interim and thinking
bubbles, the long-running heartbeat, the auto-reset notice, and the runtime failure
copy ("not processed", retry hints, generic failure apology) on a failed or empty
turn. The model's own reply is still delivered.
- Chain rules in gateway.bot_loop_guard (config.yaml): a human message starts a chain,
each bot message after it is one hop, max_hops (default 4) caps them, a bot repeating
the same text in one chain is dropped, and with bot_replies_expect_reply false (the
default) a bot answering the reply this bot just gave it starts no turn. A bot
message also counts as not expecting a reply, so a silence marker ends it quietly.
- Bot authorship everywhere it is knowable: a generic per-adapter bot_users opt-in
(config.yaml platform extra) for platforms whose events carry no bot flag,
Mattermost props.from_bot, QQ guild author.bot.
- Bot Mode message_agent: agent/bot_chain.py owns a per-session hop count carried in
the delivery's author envelope (runtime-written, parse_turn_author keeps it), refuses
sends past the same max_hops and identical repeated asks, sends a reply to the bot
that messaged this turn notify-only (expects_reply false, no wake), and refuses a
message back to a bot whose reply said it needs no answer. tools/bot_mode_dm.py gets
only the gate call and the wake flag (LANE dm owns that file; same wake names as #144789).
(cherry picked from commit b21f2e9fdc45335c55d200c2812f895d81ec28fd, #137037;
conflicts resolved onto main: ALLOW_BOTS moved to the Mattermost gateway_env AUTHZ
entry, status suppression moved to gateway/run_turn_progress.py)
Greptile on #148880 (Some waiting sessions stay silent): the status came from shared controls only,
so a turn blocked on a clarify batch, sudo, secret or vault prompt (session_server_requests) read
working, and the TUI never announced a background session waiting on one.
The cap's room.activity is written from a snapshot, so a Stop can land just
before it. That Stop fences the discussion (Continue could never reopen it),
yet the pause still read as actionable on the server and in Desktop. Both
readers now treat any Stop after the discussion's own message as final.
Gateway-hosted Group Chats gain two things:
- routing "auto" (opt-in room setting; "everyone" stays the default). The
deterministic rules run first and never reach the router: an @mention,
@all/@everyone, a scheduled post, a message inside an existing thread. An
unaddressed message otherwise gets ONE tool-less auxiliary.group_router call
over the room purpose/notes, the roster (handle, name, profile description)
and the last 5 messages, answering strict {"members": [...]} JSON. The
answer is written as room.routed BEFORE any turn is planned, so
plan_next_task stays a pure function of the log; a failed or invalid answer
records a fallback to everyone. The call runs on its own thread; its answer
is recorded only if the re-run planner still wants exactly that route, so a
Stop or a newer message during the call discards it.
- The cap (max_rounds / max_messages) still writes room.activity bounded, and
groups.continue (both the standalone and the gateway route) appends
room.continued: one more budget of rounds and messages for the same
discussion, at most 5 times. The policy checkpoint reopens the discussion's
projection from the room log, so Continue works after a gateway restart.
Desktop: "Assigned to X" rows (names, never ids), "Paused after N messages"
with Continue and Stop, a "Continued" row, and the auto choice in Room settings
when the gateway lists it (driver_status.routing_modes). All 9 locales.
tool_image_path ignored the tool and its outcome: it returned any
existing image named by a result field or by the args `path`/`url`/
`image_url`. So the CLI, the TUI sidecar and the canonical gateway's
local viewer drew the picture for a read_file of a PNG that failed with
"Cannot read binary file", a refused patch, a failed vision_analyze and
a write_file to x.png - each looking like the call succeeded.
It now takes the tool name and the call's error verdict: a failed call
shows nothing; args count only for vision_analyze (`image_url`);
results count for image_generate (`image`) and for any tool's
`image_path`/`screenshot_path` (browser_exec, computer_use), including
a multimodal envelope's `meta`. Every caller passes the verdict it
already has: the CLI hook's is_error, the canonical gateway's
_detect_tool_failure result, and the same detection in the TUI sidecar.
The TUI shows the same images as the CLI: a user turn's attachments under
its row, and a tool's image in its own transcript row under the trail line
that made it (anchored like inline diffs).
- tool.complete carries image_path, from both the sidecar gateway
(tool_progress) and the shared gateway's local-viewer fields
(local_viewer_events.complete_fields), so canonical sessions get it too.
- image.render lays an image out for the client: kitty placeholders when the
TUI owns a terminal that paints them, half-block cells otherwise, nothing
when display.inline_images is off or the file is not a readable image.
- <InlineImage> paints both with the generic cell painters. Renders are
cached across virtual-scroll remounts and each kitty image goes over the
tty once per process.
The fallback marker re-derived the resolver's answer from a few local
sources, so a window a custom endpoint reported through /models (or a
local probe, provider profile, live catalog) read as "fallback" whenever
it equalled the default. Its 60s memo was also keyed without the profile
home, so one multiplexed profile's verdict labelled another's window.
get_model_context_length now records when it ended on a static default
(the generic 256K or Bedrock's 128K) per (profile home, model, base_url),
and the marker reads that record. The memo is gone.
Gateway /model drops the configured pin for a changed route before both
the number and its label, through the same helper resolve_display_context_length
uses, so a discarded 128K pin no longer shows "(pinned)" over a fallback.
/status and /context now carry the same label as /usage for resident
agents and non-resident sessions.
Addresses Greptile review on #148801.
The gateway session line printed "◆ Context: 128K tokens (detected)" for a Bedrock
model no source knew. _resolve_gateway_model_context recognised only the generic 256K
DEFAULT_FALLBACK_CONTEXT as a default, so Bedrock's static 128K was reported as
detected. The CLI surfaces showed the number with no source at all.
agent/context_pin.py already labels a model.context_length pin "(pinned)" on those
surfaces (#66168). This commit extends the same helper with a "fallback" marker:
- is_fallback_context_length(window, model=, provider=, base_url=) is True only when
the window equals the resolver's default for that route and no local source knows
the model. On Bedrock the default is BEDROCK_DEFAULT_CONTEXT_LENGTH, and the checks
are the table/family step, a provider-confirmed cache row and model_overrides. The
generic route uses DEFAULT_FALLBACK_CONTEXT, the catalog, models.dev and
custom_providers. It does only local reads, memoised for 60s because the status bar
repaints often. It fails open and never probes.
- Labelled surfaces: the gateway session line (via context_source "default", now
worded "fallback, not a detected limit"), CLI and gateway /usage, the wide status
bar (new cli.status_bar.ctx_fallback key in all 17 locales), the welcome banner,
and the CLI and gateway /model switch summary.
No new config key. The override is unchanged: model.context_length, or
model_overrides.<provider>.<model>.context_window.
- message_agent takes optional `constraints` and `expecting`; the teammate reads them as labelled
"Constraints:" / "Reply needed:" lines under the message.
- A teammate that ends a request turn silent or empty after writing its answer elsewhere (beside a
tool call, or in a message_agent back to the sender) has those last words relayed once, marked
"[no final reply; their last message this turn:]". Notify-only and non-bot deliveries keep silence.
- A turn its own gateway's stop/restart interrupted is a failed receipt ("its gateway stopped or
restarted mid-turn ... not run again"), not `cancelled`: nobody cancelled it.
- Every live-lane completion and the fan-out join lead with `outcome`: "@beta answered." or
"@gamma never answered: <why>" plus whether resending is safe; a wait that lapses on an unreachable
owner says so (target_state "unreachable"); a busy teammate's ack says it takes the request next.
message_agent's cancel picked an admission from the target's receipt file and cancelled it with
prompt.cancel. When the first attempt had just failed transiently, the owner's one automatic retry
could be scheduled but not yet recorded: the cancel hit the finished first attempt, and the retry
then ran anyway.
The cancel is now an owner RPC, bot_relay.cancel {id, profile, author_id}
(gateway/session_bot_cancel.py). It runs under the mailbox lock that admits the retry, follows the
retry when it exists, and otherwise withdraws it for good: the receipt's retry is marked refused
"cancelled", so no wake, re-delivery or owner restart admits it, and the receipt reads cancelled.
Only the delivery's own principal and author may cancel it.
A message_agent cancel of a started request interrupts the teammate's turn (prompt.cancel on
that generation). The turn settles `interrupted` with its result, and the receipt read that as
`failed: Bot Chat delivery failed`, so the sender heard a broken delivery for a request it
withdrew. An interrupted turn with a committed result is now `cancelled` (never retried: the one
retry gate already requires outcome `failed`).
- target accepts up to 8 comma-separated teammates: each is admitted as before, and one
combined runner joins their reply runners and wakes the sender ONCE with a typed outcome per
teammate (settled/failed/cancelled/pending + reply/error/reason/target_state).
- expects_reply=false: the message is marked "[no reply needed]" after the attribution colon and
no reply runner starts (local: the payload and intent go once the owner admitted it; relay: no
waiter; peer: the sending runner runs unwatched), so nothing wakes the sender.
- cancel=<delivery_id>: the sender withdraws its own queued request, or stops the exact turn it
started, through the owner's prompt.cancel; the receipt's author must be the caller.
- The owner's deliver answer carries target_state (idle/working/waiting_approval/
waiting_clarify/paused, never stored); the ack says plainly when the teammate is parked on its
own user, and a runner whose wait lapses pending re-reads it through a same-id replay instead
of ending on a silent timeout.
- One reply path (#95074): message_agent refuses a DM to the bot whose delivery this turn
answers, since its final response already goes back. Protocol v3 names fan-out and notify-only.
Resolve a lazy session model, provider and endpoint from one effective
policy read. A following session must also ignore its retained agent
route after the profile moves; use the matching route-bound launch key
or followed config key for account eligibility without mutating the agent.
Cover both custom/OpenAI transition directions with and without a retained
agent, plus followed-account key selection. Lazy cases fail on 42500a10d9;
retained and account-key cases fail before the Sol S1 correction. All pass
with the final helper shared by the eligibility read and Fast setter.
ACP clients advertise edit, model and reasoning controls but omit speed,
so editors cannot select the backend's existing service tiers.
Read eligible choices from the owning gateway under its profile and
credential scope, and apply picks through the validated session fast
setter without changing profile defaults. Publish tier changes to attached
viewers and read the requested next-turn value independently of active
agent metadata. Pass the reconnecting client through snapshot projection
to avoid re-entering the connection lock.
Cover route/account eligibility, session isolation, reloads, cross-viewer
updates, older owners and reconnect projection. Update ACP documentation
and regenerate the canonical gateway contract.
The Android app dials /api/ws from a remote peer with a
`hermes-gateway-ticket.` subprotocol and no Origin header. The runtime
wrapper in gateway/run_api.py treated any Origin-less ticket dial as a
native local Desktop dial and closed it 4403 when the peer was not
loopback, so the Webapp's own ticket gate never saw the ticket its
/api/auth/ws-ticket had minted and nothing reached dashboard-auth.log.
Native grants are loopback-only, so a remote ticket can only be the
Webapp's: fall through to it, exactly as the same dial with an Origin
already does. Origin is client-controlled, so this opens no gate a
client could not already reach by adding one. Loopback dials still try
the native grant first, and a malformed ticket set is still refused.
Fixes#147361
(cherry picked from commit 86c2ae90766682fb36e27bc031bf5378e82903cb)
The type is whoever acts on a canonical session: an attached CLI/TUI/Desktop
viewer, the API server, the messaging control path, cron and automation. Most
call sites already name the variable `actor`. Renaming it frees "principal"
for a member of a tenant, the meaning gateway-gateway already uses
(Tenant > Membership(Principal) > Agent), so the two repos share one vocabulary.
Mechanical: whole-word rename in every module that imports it from
gateway.session_contract. No alias is kept (internal paths are not API).
Unrelated uses of the word (Windows Task Scheduler XML) are untouched.
The previous fix let a discarded unknown turn resolve after idle retirement had let its conversation go, but the captured-answer branch still indexed the session before `_publish_pending` restored it. When retirement won the race between the resolution commit and its loop-side follow-up, resolving with a captured answer raised `KeyError`, skipped the completion publication and the receipt. This also fails on main before this branch. `_resolved_unknown` now brings a retired conversation back before deciding who owns the claim, so both the captured-answer and discard cases publish and return their receipt; a drain-owned resolution is unchanged.
Reported-by: helix4u
When an unknown turn is resolved while the drain still holds its claim, `_resolved_unknown` leaves publication, delivery and collection to the drain. The drain queued collection only for a turn it settled itself, so if its recovery then failed (a storage error reading the result back, or ownership moving), the resolved turn's media was never queued and stayed on disk until the age-based sweep. The resolution now hands the collection to the drain explicitly, and the drain honours it on every exit.
Idle retirement (`session_retirement`) can let a conversation go between an unknown turn's resolution commit and its loop-side follow-up. `_resolved_unknown` looked the session up unconditionally to see whether the drain still owned the claim, so a discard without a captured answer raised `KeyError` before `_publish_pending` could restore the conversation, skipped cleanup, and left it retired. A retired conversation has no drain: treat it as not drain-owned and let `_publish_pending` restore it, as main does.
The collector only needs the admission id, but it queued whole admission rows. While a slow history scan runs, later settled turns accumulate in that queue, and an API row can carry inline images and the client's full conversation history, so those payloads stayed in memory until the earlier scans finished. Queue the id and pass it to `_release_media`.
Reported-by: greptile-apps[bot]
A drain cancelled while its reply was being handed off waits for the tracked delivery task before re-raising. When that task failed, the second wait raised the delivery error in place of the cancellation, so the drain ended as a dead pump instead of a cancelled task. Log the delivery failure and re-raise the cancellation.