Follow-up to the recovery-recipe commit on this branch, per review:
instead of only TELLING the model to re-author the payload via
write_file (2 turns), materialize the blocked command to
~/.hermes/cache/blocked-scripts/blocked-*.sh and point the recovery
at it directly: 'saved to <path> - review it, then run
terminal(command="bash <path>")' (1 turn).
Safety posture is unchanged or better:
- Nothing is executed here; the file is only written.
- The bash <path> follow-up goes through the normal execution
pipeline, including the referenced-script content guard, which
inspects script files named in commands - the payload is MORE
visible to policy than it was inline.
- Genuine hardline blocks (destructive ops) never save anything
(test-asserted).
- Save failures fall back to the previous manual write_file recipe.
- 7-day opportunistic cleanup of saved payloads.
Two block classes from production mining (250k-call window) that
models answered with blind rephrase-retries:
1. Parser-limit / malformed-payload hardline blocks (198x): these fire
on oversized inline payloads (heredocs, giant one-liners), not on a
forbidden operation - but the message read like a permanent ban.
The block now appends: 'RECOVERY: ... write the script to a file
with write_file, then run bash /path/script.sh - do not retry
inline.' Genuine hardline blocks (destructive filesystem
operations) are unchanged.
2. Backgrounding-wrapper blocks (200x): the guidance now spells out
the exact corrected call shape - 're-send WITHOUT the wrapper as
terminal(command="<cmd>", background=true,
notify_on_complete=true)' - instead of describing the feature
abstractly.
process(action='wait') hitting its window returned status='timeout'
with a terse note — models read it as an error and re-issued identical
waits (process is the #1 exact-duplicate tool call in production: 511
dupes in a 400k-msg window; wait is 57% of all process actions).
The timeout result now carries:
- process_running: true — machine-readable 'this is a status, not a
failure'
- an explicit note: 'Wait window of Ns elapsed — the process is still
running. This is not an error. Uptime: Ms.' plus the right next step:
when notify_on_complete is set, 'you will be notified on exit — do
more work instead of waiting again'; otherwise a pointer to
notify_on_complete for next time.
- the clamp note (requested > max) now composes with the status note
instead of replacing it.
Exited/interrupted results are unchanged.
Third zero-match steering tier: when a content search finds nothing in
visible files, probe once with rg --hidden --no-ignore --count-matches.
If the pattern exists only in dotdirs or gitignored files, the result
says so and tells the model to search the hidden path explicitly.
Found live by the benchmark battery: a task with a match inside
.hidden/ returned a bare 0 and the model missed the file entirely in
2/3 baseline runs.
Two dead-turn classes from production mining (state.db, recent window):
1. 13.9% of 19.6k content searches return 0 matches with no steering.
Now a 0-match content search runs one cheap rg -i --count-matches
probe (plus an rg -F probe when the pattern has regex metachars) and
attaches what it found: '0 exact matches, but N case-insensitive
matches — casing may be wrong' / 'N literal matches — metacharacters
need escaping'. True zero-match results stay clean (no noise).
2. 122 'Path not found' failures came from models passing several paths
in ONE path string ('dir1 dir2 dir3', comma lists). Instead of
failing wholesale, split the string, search every path that exists,
merge results, and report skipped parts in a warning. Single-path
misses keep the existing Similar-paths hint; all-missing multi-path
strings still error.
Both probes are bounded (count-only rg, 30s timeout, max 2 invocations)
and wrapped so a probe failure can never break the search result.
- New website/docs/user-guide/messaging/a2a.md: when/where to use A2A
(cross-machine, specialist peers, being callable) vs delegation/kanban
for same-machine multi-agent; enable, outbound tools, inbound surface,
security model, env reference, quick test, troubleshooting. Registered
in sidebars.ts and the messaging index.
- README/DESIGN/plugin.yaml/protocol.py prose updated to name the A2A
v1.0 canonical discovery path /.well-known/agent-card.json (the code
already served both; only the docs lagged).
A2A authenticates every inbound request via bearer token in do_POST
(401 before dispatch). Without overriding authorization_is_upstream=True,
the gateway's per-platform user allow-list ({PLATFORM}_ALLOWED_USERS)
rejects A2A peers because their identity is a token-derived name or
pod IP, not a platform account in any configured allow-list. Messages
never reach the agent and callers get empty replies.
This is authorization delegated to the bearer-token transport, not a
fail-open: every request is 401'd if the credential is wrong.
Reported by kuangmi-bit (PR #41711 comment, Jun 27).
Attribution: gfdsa's a2a-hermes repro fixture (LOCAL PATCH triad-hermoperator).
The other two patches from gfdsa's fixture were already in our branch:
- k8s Agent Card URL derivation (_request_public_url, commit ea59b85c2)
- send() gating on metadata['notify'] (reply-capture fix, commit ea59b85c2)
All 168 tests pass (151 unit + 17 integration).
Two bugs reported by gfdsa (PR #41711 comment, Jul 12) that break the
official a2a-sdk 1.1.0 Python client:
1. Task objects serialized non-spec createdAt/lastModified fields.
The A2A v1.0 Task proto (lf.a2a.v1.Task) only has id, contextId,
status, artifacts, history, metadata. Strict ProtoJSON parsers
reject unknown fields with ParseError. Removed both fields from
build_task(); created_at param kept for call-site compatibility.
2. SSE streaming frames were not JSON-RPC wrapped. A2A v1.0 §9.4
requires data: {"jsonrpc":"2.0","id":...,"result":{StreamResponse}}.
sse_data() now accepts req_id and wraps in JSON-RPC envelope.
sse_done() changed from 'data: {}' to SSE comment ': done' so
SDK doesn't try to parse an empty JSON-RPC response.
All call sites in adapter.py (_emit_terminal, _rpc_message_stream,
_rpc_tasks_subscribe) updated to thread req_id through.
Tests updated: 153 pass (151 unit + 17 integration, including 2 new
tests for JSON-RPC envelope wrapping and fallback behavior).
Refs: gfdsa/a2a-hermes reproduction repo
## File/data Parts (v1.0 unified Part)
- file_part(url=, raw=, filename=, media_type=) builds v1.0 file Parts
- data_part(data, media_type=) builds v1.0 data Parts
- message_with_parts(role, parts, context_id=) builds Messages with mixed Part types
- extract_text now renders file/data Parts into the text stream:
- File with URL: '[file: name] https://url (mediaType)'
- File with raw: '[file: name] N bytes base64-encoded (mediaType)'
- Data: '[data (mediaType)]\n{json}'
- v0.3 file (file.fileWithUri) and data (kind=data) still accepted
- Outbound replies stay text-only (agent produces text)
## Push notification config full CRUD
- get_push_config(task_id, config_id) — retrieve by task, optionally by configId
- list_push_configs(task_id) — list all configs for a task (max 1 per task)
- delete_push_config(task_id, config_id) — remove a config
- New JSON-RPC methods: tasks/pushNotificationConfig/get, /list, /delete
- New adapter handlers: _rpc_push_config_get, _list, _delete
- All return spec-shaped PushNotificationConfig with configId + createdAt
## Tests
- 6 new unit tests for Part builders + extract_text with file/data
- 13 new unit tests for push config get/list/delete (happy + error paths)
- 2 new integration tests over real HTTP:
- test_mixed_parts_delivered_to_agent: file URL + data JSON reach agent
- test_push_config_crud_over_http: full create→get→list→delete cycle
- Old test_extract_text_skips_non_text_parts replaced (now renders, not skips)
Total: 151 tests (134 unit + 17 integration), 0 failed.
DESIGN.md updated: file/data Parts and push config CRUD removed from
out-of-scope list.
Consolidates 5 follow-up PRs onto the a2a-work branch:
1. Reply-capture fix (#56437): adapter.send() now only resolves the
blocked RPC Future when metadata['notify'] is True (the gateway's
final-reply marker). Interim sends no longer short-circuit the
response. Also accepts **kwargs in connect() for reconnect compat.
2. Slash command passthrough (#53743): wrap_inbound() passes /-prefixed
text through unwrapped so the gateway command processor sees it.
Fixes /sethome deadlock during A2A onboarding. Documented security
trade-off (bearer auth at network layer compensates).
3. Routable URL in Agent Card (#53736): _build_card() now derives URL
from A2A_PUBLIC_URL env > X-Forwarded-Host/Host header > bind host.
Fixes k8s bug where Agent Card advertised 0.0.0.0.
4. contextId multi-turn memory (#53756): _handle_inbound_task() now
checks top-level params.contextId first (A2A spec), falls back to
params.message.contextId (legacy). Outbound a2a_call also sends
contextId at both top-level and inside message.
5. Type checker fixes (#53759): TypedDict for _SCHEMAS, _FunctionSchema,
_ToolSchema. Removes str() band-aid casts.
All 45 tests pass including new tests for each fix.
Zero core files modified — only plugins/platforms/a2a/ and tests/.
Credits: @davidrobertson (#56437), @knoal (#53736, #53743, #53756,
#53759), @kuangmi-bit (slash command bug report), @gfdsa (k8s URL bug
report), @shivasymbl (#45996 userContext OBO).
Live Tier-3 testing (CLI agent -> a2a tools -> live peer gateway -> model)
surfaced two bugs the kwarg-style unit tests masked:
1. registry.dispatch calls handlers as handler(args, **kwargs) — args is the
whole dict positional. The handlers used keyword params (url=, agent=), so
the dict bound to the first param and .strip() raised
'dict object has no attribute strip'. Rewrote all three handlers to take
args: dict (matching the spotify/google_meet convention). Added a
registry-dispatch regression test that exercises the real call path the
direct-kwarg tests never hit.
2. The model repeatedly reached for agent_name= instead of agent= (6 retries
before success). Accept agent_name/name and message/text/task aliases so a
reasonable guess succeeds first try.
Verified live: client agent discovers the peer's Agent Card, calls it, and
gets the reply back (PONG round-trip confirmed on both client audit log and
peer conversation log). 39 plugin tests pass.
The a2a client tools are registered unconditionally by the plugin, but a
newly-registered plugin toolset defaults to ENABLED for every platform until
the user has seen it in 'hermes tools'. That force-injected 'a2a' into every
agent's enabled_toolsets, leaking 3 tool schemas to all users and breaking
tests that assert exact toolset membership
(test_api_server_toolset::test_create_agent_respects_config_override).
Add 'a2a' to _DEFAULT_OFF_TOOLSETS so it stays opt-in (user enables via
'hermes tools'), matching the spotify precedent. The inbound platform
adapter is already opt-in (only instantiated when the a2a platform is
enabled); this aligns the outbound client tools with the same posture.
Single platform-adapter plugin under plugins/platforms/a2a/ — zero core
edits — that supersedes the entire A2A PR/issue cluster. Built on the
ctx.register_platform + ctx.register_tool surface the codebase now exposes.
Outbound (a2a toolset): a2a_discover / a2a_call / a2a_list let the agent
call any A2A-compliant peer over JSON-RPC message/send. Inbound (platform
adapter): a stdlib http.server serves an Agent Card at
/.well-known/agent.json and routes incoming tasks into the agent's LIVE
gateway session (the #11025 insight) — same agent, full memory — returning
the reply over A2A.
Security on by default: no bearer token => 127.0.0.1-only bind; constant-
time bearer auth; inbound prompt-injection filtering + untrusted-peer
framing; outbound credential redaction; append-only audit log; per-context
conversation persistence outside the compaction pipeline.
Stdlib only (no a2a-sdk). 37 tests incl. a live HTTP round-trip
(card + message/send + reply) and a bearer-auth 401 path.
Production mining (state.db, 400k-msg window): 60.2% of 104k terminal
calls carry a defensive 'cd X && ' prefix (~925k tokens of pure prefix)
and 2,462 failed calls led with cd — the model cannot see cwd state, so
it re-asserts it on every call and runs pwd/ls diagnostics after
directory changes.
The result dict now includes a 'cwd' field whenever the session cwd
after the command differs from the cwd it started in (cd, pushd,
chained cd). Stable-cwd commands are unchanged (no field, no noise).
Per-command workdir overrides stay transient by contract and never
echo. Schema note added so models learn to trust session cwd instead
of prefixing. Pattern borrowed from crush's <cwd> injection.
realpath comparison avoids false echoes through symlinks; the echo is
wrapped defensively so a backend without .cwd can never break the
result path.
The #1 patch failure class in production (state.db mining, 250k-window)
is a re-send of an edit that already landed: 'old_string and new_string
are identical' (299 occurrences) plus a share of hunk-not-found errors
where the new text is already in the file. These errored, sending
models into re-read/re-patch loops.
New tools/fuzzy_match.is_already_applied(content, old, new) — a
conservative check requiring (1) non-trivial new_string (>=8 chars),
(2) EXACT presence of new_string, (3) old_string gone (unless
identical). Wired into three sites:
- patch_replace (replace mode): returns success + no_change: true +
an explicit note instead of the identical-strings / no-match error.
- V4A validation phase: an already-applied hunk validates as a no-op
so multi-hunk patches no longer fail wholesale when one hunk landed
in a prior call.
- V4A apply phase: mirrors the same skip so the two phases agree.
Genuine no-matches (new text absent) and half-applied renames (old
text still present) keep their error behavior — covered by tests.
Production mining (state.db, 28.5k read_file calls in the recent
window) shows 74.3% of reads truncated — nearly all by the 500-line
default, not the char budget (22,443 line-limit vs 13 char-budget
truncations). That churn produced 12,229 redundant re-reads, and after
a truncated result the most common next move was fleeing to terminal
cat/sed (4,608 times) — the pagination contract was not trusted.
Median truncated file is 2,422 total lines, so a 2000-line default
makes 44% of today's truncated first-reads complete in one call while
the unchanged ~100K-char budget still caps worst-case result size
(same ceiling as before: 500 lines x 2000-char line cap = the same
100K). Schema max was already 2000.
Touchpoints: DEFAULT_READ_LIMIT + both read_file signatures
(file_operations.py), read_file_tool + schema text (file_tools.py),
execute_code sandbox stub docs (code_execution_tool.py), 3 tests
pinning the old default.
Builds on the adapter list_channels() hook (cherry-picked from #43545 by
@Guoen0):
- plugins/platforms/simplex: implement list_channels() — enumerates
contacts (/contacts) and groups (/groups) over the live daemon
WebSocket into the channel directory. Returns None when the WS is
down so the directory falls back to session discovery instead of
wiping known targets.
- hermes send --list: merge configured-but-undiscovered platforms into
the listing. Previously a platform configured only via env (e.g. a
fresh SimpleX setup used for outbound sends) was silently omitted,
leaving users guessing at platform names.
- format_directory_for_display(): accept an explicit platforms view and
render empty platforms with a targeting hint instead of hiding them.
- docs: simplex hermes-send section.
Reported by Fedpostoffice on Discord (simplex missing from
hermes send --list; guessed platform names simplex-chat/simplex-relay).
When a command exits non-zero, scan the first 4KB of output for
well-known failure shapes and attach one short, actionable recovery
hint to the tool result ('hint' field):
- gh 'Unknown JSON field' (9.2k occurrences in a 250k-call window)
- git merge conflicts (1.2k) — stop verbatim retries
- command not found (1.0k), incl. python->python3 and pip->pip3
- ModuleNotFoundError (739) — venv activation guidance
- 'already exists' (633), gh rate limits (133), permission denied
- exit-code-only tier: 124 timeout, 126 not-executable, 137 SIGKILL
Hints are suppressed when the existing exit_code_meaning tier already
explains the code (grep=1 etc). Pattern order = production frequency
from state.db mining; first match wins; pure function, no I/O.
The delivery worker is a daemon thread, so a short-lived process
(hermes chat -q, a cron session) could exit right after firing
on_session_end — silently dropping the headline event. Register a
bounded atexit flush (5s) when the worker starts: a dead endpoint can
delay exit slightly, never hang it.
Live-verified: hermes chat -q now delivers on_session_start,
post_tool_call, and on_session_end to a real receiver; regression test
runs a subprocess that exits without flushing (sabotage-verified).
- delivery_id is now generated once per firing and used for both the
X-Hermes-Delivery header and the signed body's delivery_id field —
previously they were two different uuid4s, breaking receiver-side
dedupe as documented.
- 3xx responses are no longer followed: urllib's default redirect
handler converts a redirected POST into a body-less GET, silently
dropping the signed payload. Redirects now log a misconfiguration
warning and count as delivery failure (no retry).
- Docs: receiver-side replay-protection guidance (dedupe on
delivery_id, timestamp freshness window) + redirect semantics.
- Tests: 5xx retry count, redirect-not-followed (sabotage-verified),
header/body delivery_id equality.
The inverse of the inbound webhook platform: hooks.outbound in
config.yaml lists HTTP targets + the plugin-hook events they subscribe
to (on_session_end, subagent_stop, post_tool_call, ...). Each firing
POSTs a JSON payload (same top-level shape as shell hooks' stdin wire)
signed GitHub-style with HMAC-SHA256 (X-Hermes-Signature-256).
Rides the existing hook bus — notify-only callbacks registered on the
plugin manager at the same CLI/gateway/main entry points as shell
hooks. Delivery is fire-and-forget via a bounded queue + single daemon
worker thread, so a dead endpoint can never stall a tool call. Bounded
retries (5xx/conn errors once; 4xx never). secret_env preferred over
inline secret. HERMES_SAFE_MODE skips registration. hermes hooks list
shows outbound targets with signed/UNSIGNED status.
Zero new model tools, zero new subsystems.
Answers and written deliverables that rest on retrieved information now get
inline numbered citations plus a mechanically-rendered Sources list, with a
persistent ledger that makes a hallucinated citation detectable.
- skills/research/grounded-citations/scripts/sources.py: stdlib citation
ledger (add/ingest/list/render/verify) at
$HERMES_HOME/cache/citations/ledger.json, profile-aware, O_EXCL-locked so
parallel subagents sharing a ledger can't collide on ids
- SKILL.md: cite-while-drafting procedure, register-at-retrieval rule,
pitfalls, verification gate
- references/citation-formats.md: per-target placement (markdown, LaTeX/PDF,
docx, pptx, xlsx, wiki, BibTeX handoff to research-paper-writing)
- references/grounding-rationale.md: why numbered ids (ALCE 2305.14627,
WebGPT 2112.09332, Perplexity marker conventions), and how this relates to
the in-process registry in PR #44833
- tests/skills/test_grounded_citations_skill.py: 30 tests
The LRU-cap test in tests/tui_gateway/test_protocol.py stubs
_close_session_by_id with a two-arg lambda; the reaper now passes a
revalidation predicate. Widen the stub signature.
The Discord semantic thread-rename lane resolved the target thread from
`_relay_auto_thread_info`, which read a single-slot-per-parent-chat cache
(`adapter._auto_thread_by_chat[chat_id]`, populated from connector
SendResult feedback). When two auto-threads spawned from the SAME parent
channel, the second send overwrote the first's slot and the title turn's
read raced the write — so only the FIRST thread in a channel ever got its
semantic rename. Staging repro 2026-08-02: message A's thread renamed to
"A Hundred Word Sword Story", sibling message B's thread stayed stuck at
the raw first-words name.
The connector now stamps `prospective_thread_id` on the inbound (the anchor
message id, which is the id of the thread it will auto-create) — shipped for
per-thread session keying. Reuse it here: it is deterministic and
per-message, so it names the EXACT thread even when several auto-threads
share one channel. `_relay_auto_thread_info` returns it directly (with an
empty initial-name marker) and never consults the collision-prone per-chat
cache; the connector's own created-name guard (`prefer_connector_created`)
still enforces no-clobber, so no initial name is needed gateway-side. The
send-result cache path stays as a fallback for older connectors that don't
stamp the field.
Tests: two new cases in test_relay_threads.py — prospective id wins over a
poisoned cache entry, and two sibling threads in one channel each rename to
their own thread id. Full gateway session + relay suites green (211 passed).
- Guard the thread-id-as-chat_id normalization to Discord only; Slack
and Telegram adapters use parent_channel as chat_id for thread messages,
so the unconditional version broke their handoff keys.
- Apply the same Discord-specific guard to _seed_cron_thread_session in
cron/scheduler.py (sibling site with the same bug, docstring said
'Mirrors _process_handoff').
- Replace the change-detector test with contract tests that verify the
actual invariant: Discord handoff key == organic thread key, Slack
handoff key still uses parent channel (non-regression).
A CLI→Discord handoff creates a dedicated thread and re-binds the CLI
session to it. It built the destination SessionSource with
chat_id = home.chat_id (the PARENT channel) while marking it
chat_type="thread" with thread_id set.
But platform adapters build organic in-thread messages with
chat_id = <thread id> (see the Discord adapter's on_message and
_build_thread_event paths). build_session_key therefore produced two
different keys for the same thread:
handoff: agent:main:<platform>🧵{parent}:{thread}
organic: agent:main:<platform>🧵{thread}:{thread}
So the next real user reply in the handoff thread resolved to a
DIFFERENT session_key and spawned a fresh session instead of continuing
the handed-off one — observed as a stray auto-titled session plus a
session_search fallback (the new session had no prior context).
Fix: for a thread destination, key on the thread's own id so the
synthetic handoff turn and later user replies share one session_key,
matching how adapters key organic in-thread messages.
Adds tests/gateway/test_handoff_thread_session_key.py, which asserts the
handoff key is byte-identical to the organic in-thread key (fails on the
old parent-channel keying, passes on the fix).
The _sync_codex_entry_from_auth_store source guard returned early for
source='manual:device_code', which is the recommended quarantine-safe
configuration (hermes auth add openai-codex produces SOURCE_MANUAL_DEVICE_CODE).
The PR's fix for refresh_token adoption was unreachable for these entries.
Widen the guard to accept both 'device_code' and 'manual:device_code'.
Follow-up to #70111. Issue reporter (imgyf) confirmed this caused a
12-of-16 fleet outage on Aug 1.
Co-authored-by: imgyf <imgyf@users.noreply.github.com>
Two defects in the openai-codex credential pool recovery path:
Defect 1 — adoption path silently no-ops when store_access is empty
_sync_codex_entry_from_auth_store() skipped adoption when the auth
store had no access_token (only last_refresh). When another process
rotated the token pair, the stale profile's entry kept the consumed
refresh_token and replayed it, getting refresh_token_reused and going
terminally DEAD.
Fix: also adopt when store_refresh differs from entry_refresh, even
when store_access is empty. Keep the entry's existing access_token
in that case (store_access or entry.access_token).
Defect 2 — false 'auth refreshed' success log
_try_refresh_codex_client_credentials() returned True whenever
resolve_codex_runtime_credentials() returned any non-empty credentials,
including the same stale token when the underlying refresh failed.
The conversation loop then logged 'auth refreshed after 401' right
before the retry failed with the identical token_expired.
Fix: compare the access token before/after the refresh. If unchanged,
return False so the 401-retry path logs the truth.
Fixes#70097
Replace the process-global HERMES_CRON_SESSION env var with a per-session
ContextVar so a cron tick in the gateway process cannot leak into unrelated
live gateway/API/TUI turns. The cron scheduler now sets the ContextVar
inside the job's try/finally scope and resets it on cleanup. Gateway, API
server, ACP adapter, and TUI gateway all pass cron_session='' to explicitly
mark their sessions as non-cron, masking any stale process env.
Co-authored-by: hinablue <hinablue@gmail.com>
Closes#37968
Completes the cache-parity bug class from #76938: the parent's request
body diverges from the fork's not only at the ephemeral system prompt
but also at prefill messages (inserted right after the system message
at API-call time) and, on OpenRouter, at upstream-provider selection
(prompt caches live per upstream; an unpinned fork can be routed to a
different upstream and miss a byte-identical prefix).
Also hardens the tests: pairwise asserts instead of re-implementing the
production prompt join, and routed-path omission guards for the whole
gated kwarg family.
_format_price_per_mtok collapsed any per-Mtok price below one cent to
"$0.00" (and readers treated near-zero as free). Nous Portal's DeepSeek
V4 Flash 0731 promo prices cache hits at $0.0018/Mtok, which rendered as
free in the /model picker and hermes model listings.
Prices under $0.01/Mtok now widen precision to the first significant
digit plus one, with trailing zeros trimmed: 0.0000000018/tok →
$0.0018. Standard prices keep the aligned two-decimal format; exact
zero still renders as "free".
Simplify-pass follow-up on the #68157 salvage (regression-neutral \u2014 the
old temp-file version was equally bare): capture ffmpeg's -loglevel
error output so a CalledProcessError carries the real message, and log
it at the voice-input catch site. Parity with transcription_tools'
ffmpeg call sites. Live-verified: forced ffmpeg failure produces the
captured message in the exception.
pcm_to_wav staged every captured utterance in a NamedTemporaryFile just to
hand ffmpeg an input path, then unlinked it. Feed the PCM to ffmpeg's stdin
instead: one fewer file created, written, read back and removed per voice
utterance, and the try/finally cleanup goes away with it.
The WAV output deliberately still goes to output_path rather than being
captured from stdout. ffmpeg cannot seek on a pipe, so a piped WAV is
written with placeholder 0xFFFFFFFF RIFF/data chunk sizes -- Python's wave
module then reports 2147483647 frames for a 1s clip, and strict readers
misjudge the length. Writing to the real path lets ffmpeg seek back and
patch the header.
Tests cover both halves: that the PCM goes over stdin with no temp file,
and (when ffmpeg is installed) that the resulting header reports the true
frame count.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Pass extra_exclude={pid} to _reap_unsupervised_gateway_orphans so the
killed PID isn't double-killed during the sweep (#75936).
- Add extra_exclude param to _reap_unsupervised_gateway_orphans signature.
- Replace bare except:pass with logger.debug for diagnosability.
- Fix existing test (mock _reap_unsupervised_gateway_orphans so it
doesn't scan real processes and trigger conftest live-system guard).
- Add regression test asserting the killed PID is excluded from the sweep.
stop_profile_gateway() only killed the single PID recorded in the pid
file. On repeated restarts, the new process overwrites the pid file
before the old one exits, making older gateway instances invisible
to subsequent stops. Each restart stacked another orphan — observed
as N threads and N replies per Discord message.
After killing the recorded PID, also call _reap_unsupervised_gateway_orphans()
to sweep any remaining gateway processes for this profile. The reap function
already handles the no-systemd guard and SIGTERM+SIGKILL escalation.
Fixes#75936
Re-applied from #50508 onto current main: the executor moved from
tools/ to agent/ and the JSON-error site was refactored away into
_parse_tool_arguments, but these 8 f-string debug calls survived the
move verbatim. Lazy %s formatting skips string interpolation when
DEBUG is off — the Tool-result line interpolates the FULL tool output
(can be 100KB+) on every tool call otherwise.