Gate finding C01 quality [Low]: since #133399 the helper also runs in the
reverse direction (failed candidate -> retained predecessor), so the
hand-back calls read hand_over_held_inbound(adapter, predecessor) against a
(predecessor, adapter) signature. Neutral source/target names match both
directions. Every caller passes positionally, so no call site changes.
Gate finding C01.r1.2c [High]: the hand-back added for failed candidates
also ran in the post-install exception/cancel branches. After a successful
secondary install (or a primary publish) the predecessor already forwards
to the new adapter; if the task was cancelled during redelivery (gateway
teardown cancels secondary reconnect tasks) the hand-back linked the new
adapter back to the predecessor, and any hold - e.g. the disconnect()
teardown salvage - ping-ponged between the two until RecursionError.
Break the cycle at both ends: adopt_held_inbound clears the adopter's own
successor link (it owns the queue again), and the runner skips the
hand-back when the adapter is the one installed in its slot. Covered by a
cancel-after-install row on the secondary hand-back test.
7ef5aca075 (#133228) handed held Telegram inbound over only when a
replacement was published, on the premise that a candidate that fails to
connect never owns the hold queue. That is false: TelegramAdapter.connect
failure sets a retryable fatal error, so updates PTB already acked are held
on the candidate, and disconnect() salvages pending text batches into the
same queue. The runner then disposed the candidate and those events were
lost for good.
After every candidate dispose path (primary retryable and exception
branches, secondary connect-raised teardown, secondary not-installed
teardown and the outer secondary except), hand the candidate's held events
back to the retained predecessor with the existing hand_over_held_inbound.
The predecessor's queue then moves to the eventual replacement at publish,
and the candidate's successor weakref forwards any late hold.
Fixes#133399
Reported-by: j2h4u (Maxim Brashenko)
join_voice_channel(channel, text_channel_id=..., source=...) is the programmatic join API, and it
kept the requested binding only on a cold join: the already-connected paths (same voice channel, or a
move) returned before writing it, so a successful join to another text channel kept transcribing
into the old one. All three paths now apply the binding through one helper. Moving to another text
channel drops speech still buffered for the old one, as /voice join does, and the old channel's bound
source unless a new one is given: its chat would keep routing turns into the old conversation.
With no bound source, a voice turn built a synthetic one with chat_type "channel". A typed message in
a guild text channel is "group" (or "thread" under its parent), so the voice turn keyed another
session. It now goes through build_source as a typed message does, with the guild, thread and parent.
/voice join, the only in-repo caller, already writes its binding itself after joining, so this is
latent until something calls join_voice_channel with a text channel (the open auto-join PRs would).
Reported by @andrexibiza as F7 in #132474.
guild.get_member() is cache-only, so a speaker who had never typed in the
guild (no member intent, or joined voice straight away) rendered as the bare
user id while their text turns render a display name: the session-context
prompt differed again on every switch. Fall back to the cached global user
(global name / username) before giving up; a guild nick still wins when the
member is cached. Suggested in the first review, reproduced independently
(F3 on the thread).
[adapted onto main: #133463 resolves the speaker name inline in _voice_input_source, not
through DiscordAdapter.voice_member_display_name, so the fallback is a small helper there. It
runs after the /voice join invoker's bound name, which is the nickname their typed turns
carry; a cached global name would differ from it. Tests moved to
test_discord_turn_prompt_parity.py]
(cherry picked from commit 295a01f3ab88f07f3bac22f8939c42bca928c398)
A typed `/voice join` binds that message's source, message_id included, and
`_voice_input_source` rebuilt every voice turn from it. The join message is
not the voice turn's trigger: run.py exports source.message_id as
HERMES_SESSION_MESSAGE_ID (reply anchoring), and on main it also set the
presence bit in the prompt-pin key, so the note-gating cache miss this PR
fixes only surfaced for native slash-command joins. Clear it on the way out.
Review-fix for #127944.
[adapted onto main: #133463 resolves the speaker name inline in _voice_input_source, so the
clear lands in main's block; the test moved to test_discord_turn_prompt_parity.py, a typed
`/voice join` id on the bound source]
(cherry picked from commit fe70c11bdabdb1ea0b9494880146f4cf16b19ad0)
With DISCORD_ALLOWED_ROLES and no user allowlist, the voice listen loop
admits a role member through the adapter's guild-scoped role check, but
_handle_voice_channel_input rebuilds the turn's source from the
`/voice join` copy. SessionSource.to_dict() deliberately drops the
per-event role_authorized grant, so the gateway's own check saw a
principal with no grant and dropped every spoken turn from a role-only
member, while the same member's typed messages were answered.
The grant is now recomputed for the current speaker against the guild's
current member, with the meaning the message path gives it (the adapter
has a role allowlist and this user passes its gate). It is never
serialized and never inherited from whoever ran `/voice join`: a speaker
without the role, or no longer in the guild, is still refused.
Reported by andrexibiza (F4, voice half) on #128788.
(cherry picked from commit 6985ea6300671466b8cfa8e7948ee4c402bcb9e2)
`ctx.inject_message` could only reach an existing gateway session (`session_key`), so an
orchestrator plugin that opens a chat/thread itself (a bot-authored Discord forum post never
comes back as an inbound message) had no way to get an agent turn there.
`origin` takes the SessionSource.to_dict() shape. The gateway creates (or continues) that chat's
session through the adapter's normal message path, in the profile whose plugin manager loaded the
plugin, and the reply goes back to the origin chat:
- Profile: resolved from the manager's immutable home, never an ambient one. The origin may not
name another profile, the gateway's own routing for that chat through that profile's adapter
must land in the plugin's profile, and the profile needs its own connected adapter for the
platform (another profile's bot is never borrowed). Refusals are synchronous False + one log line.
- Authorization: same current-allowlist check as the session_key path (no bypass for host code).
- Multiplex: handle_message runs inside the profile's runtime scope so the spawned turn AND the
reply send after it inherit that profile's home and secrets (applies to the session_key path too,
which used to send from the default loop context).
- allow_gateway_injection is read from the plugin's own profile config.
- Plugin/system turns (event.internal) skip the human first-contact notes (onboarding intro and
the "no home channel" nudge), which were posted into the plugin's fresh thread.
Requested by canonicle-desk in #132609.
Closes#132609
run_inbound.py was past the 2,000-line gate; the injector install/clear,
scheduler and dispatch move verbatim to gateway/run_plugin_injection.py
(GatewayPluginInjectionMixin). No behaviour change.
The receiver buffers PCM per speaker with no conversation identity, and a same-voice-channel /voice join keeps that receiver. The poll and the leave flush then read the binding after draining, so audio captured for channel A was stamped with B and passed the post-STT check. The gateway join handler now discards the receiver's unemitted audio, under its lock, before moving the binding to a different text channel; a first join or same-channel rejoin keeps it. Nothing awaits between the discard and the binding write.
Reported by andrexibiza (F1) on #133471 and #130312.
_is_duplicate_voice_transcript keeps one runner-wide store keyed by
(guild_id, user_id). When one gateway serves several Discord bots
(multiplexed profiles) listening in the same guild, each bot transcribes
its own copy of an utterance; the first callback records it and the
second returns before building its event, so that bot never answers.
A `/voice join` from another text channel also inherited the previous
binding's 12-second history.
The key is now the receiving bot's profile, the guild, the bound text
channel and the speaker. A repeated capture inside one binding is still
suppressed, as #19428 intended.
Reported by andrexibiza (F5) on #128788.
(cherry picked from commit 0b889d02455584315913cf29076bb9952f8489e5)
The 10 s concurrent-load bound also hit slow but healthy first imports. A
second agent built while a provider's import took longer than 10 s got no
memory provider for its whole session, and with checkpoint_required every
compaction then failed closed.
The bound now applies only inside plugin_loader.bounded_load_wait(), which
the per-turn gateway memory identity signature uses. Other callers (agent
builds) wait for the in-flight load. Cycle detection still breaks a
cross-thread deadlock for every caller.
resolve_channel_skills matched the channel id and the parent id in one pass
over the bindings, so the first listed entry won. With the parent's entry
listed first, a Discord thread with its own binding started its session on
the parent's skill. Look the exact id up first, then the parent id, as the
docstring and resolve_channel_prompt already do.
(cherry picked from commit 4005d55edd7a113fcdc53b6b9cf3f0a47419350b)
With the discord toolset loaded, a typed turn rendered the per-turn
triggering-message line and keyed the agent cache on message_id presence,
while slash, /thread and voice turns (no triggering message) did not, so
the pinned session-context prompt re-rendered on every switch between
them. The line is now static and the presence bit is out of the key; the
real id still rides the user message when there is one.
Reported by teknium1 in the #132758 review; fix shape from andrexibiza's #132758.
Co-authored-by: Andrex Ibiza, MBA <andrexibiza@gmail.com>
(cherry picked from commit 2a3f04a67d1aa6dc43628ce0959ddb699baa84f8)
A spoken turn rebuilds its source from the copy `/voice join` stored, and
three of its prompt inputs still differed from a typed message in the same
conversation:
- voice joined from a thread looked up the channel prompt and bound skills
for the thread alone, while a typed message there falls back to the
parent channel's entry, so a parent-only binding never reached speech;
- the chat name and topic stayed at their join-time values, so after a
rename or topic edit every typed/spoken switch re-rendered the pinned
context prompt and back;
- `guild.get_member()` is cache-only, and on a miss the speaker was named
by numeric id even when they were the member who ran `/voice join`.
The resolvers now get the stored parent id, the name and topic come from
the bound channel through the same formatters a message uses (the stored
values stay as the fallback when the channel is not cached), and an
uncached joiner keeps the name bound at join; any other uncached speaker
still gets their id, never the joiner's name.
Reported by andrexibiza (F1, F2) and teknium1 (get_member) on #128788.
Co-authored-by: Matt McFarland <mattttmcfarland@gmail.com>
(cherry picked from commit b993d20b240b34a61cf9c54dbd52d9050680f497)
Native slash commands (/skill, /plan, /queue), the /thread starter and
voice-channel input are human turns, so each re-pins the session-context
prompt and the channel prompt. They built their event without the fields
on_message sets:
- a DM slash turn had no chat name, so the "origin" line rendered the
channel id instead of the user's name;
- a thread slash turn and the /thread starter dropped the parent channel
from the thread label, and the starter took the parent channel's topic;
- a spoken turn named the speaker by numeric id, not display name;
- none carried the channel's bound skill, so a session opened by one of
them never loaded it.
Each switch between one of these turns and a typed message re-rendered
the pinned context prompt and back again, a prompt-cache miss each way.
They now use the same formatters (_format_thread_chat_name,
_get_effective_topic on the thread, the member display name) and
_resolve_channel_skills as the message path.
(cherry picked from commit c2727ba500f22249dd729aba6365af3bb3c5e970)
rearm_failed_platform takes the manager's discovery lock, which a reload sweep or a deferred load holds for its whole import, so call it through the scope-carrying executor hop like the lookup. The hung-load guard now matches the loader thread name through one _LOADER_THREAD_PREFIX constant instead of a copied literal. Gate r2 Lows (2c, efficiency, quality).
Gate finding (PL1 r1 efficiency, Medium regression): after a re-arm, the
next watcher tick ran the plugin's import + register() inside the sync
_create_adapter on the event loop, and run_with_load_deadline joins for up to
plugins.load_timeout_seconds, which stalls inbound messages, heartbeats and
the control socket.
The watcher now materializes the registry entry with platform_registry.get
in the gateway executor first, using _run_in_executor_with_context so the
profile ContextVars are preserved, and _create_adapter then finds the plugin
already resolved. The rest of _create_adapter stays on the loop, as it was
on main. Builtin adapter constructors ran there before this stack and may be
loop-affine, so moving the whole call into the executor would change
unrelated behaviour.
Gate finding (PL1 r1 efficiency, Medium): a permanently broken platform
plugin was re-armed and re-imported on every reconnect backoff tick
forever, re-logging its load failure each time.
The watcher now counts successful re-arms in the platform's reconnect info
dict and stops after 3 (_MAX_PLUGIN_LOAD_REARMS), so a transient startup
failure still heals while a broken plugin stops being re-imported. The
platform stays queued as adapter_unavailable, as before this stack. No new
config key.
Gate finding (PL1 r1 quality, Low): _rearm_failed_platform_load was a
one-line forwarder in the handler-rewire mixin, unrelated to rewiring, and
its bool result was discarded at its only caller. Call the plugin manager
directly at the run_adapters call site instead.
A bundled platform plugin whose deferred load raises or overruns its load
deadline at startup (e.g. I/O contention from a state.db integrity check) has
its lease disposed, so the registry forgets the platform. The reconnect
watcher keeps it queued as adapter_unavailable, but nothing ever re-runs the
import: the platform only heals after a manual reload-plugins or a restart.
The watcher's adapter_unavailable branch now asks the current profile's
plugin manager to re-lease that platform's deferred loader (only when the
plugin is recorded as a failed platform load), once per backoff tick, so the
next pass retries the import. No new config key; the watcher already owns the
bounded backoff.
Refs #126356
Co-authored-by: Zheqing Zeng <26277217+zengzheqing@users.noreply.github.com>
Co-authored-by: JerryLiu369 <953639086@qq.com>
Gate finding B05-3: once the reconnect entry stores the predecessor
adapter instead of a snapshot of its caches, carry_inbound_dedup is the
only caller of inbound_dedup_caches, and its "held by reference" docstring
described the old snapshot contract. Inline the isinstance filter into
the loop (fewer lines than rewriting the docstring; no tests reference
the helper) and return early on a missing predecessor instead of a
ternary.
Telegram holds inbound events that PTB has already acked (offset advanced),
so dropping them means losing the message for good. The hold queue lives on
the adapter instance and drains only on that instance's _mark_connected. When
a retryable fatal (network error, polling/ingress stall) makes the runner
build a NEW adapter, that drain never runs: the reconnect path carried the
inbound dedup caches across but not the queue. Held events, plus anything
teardown salvaged onto the retired instance later, were silently stranded.
The reconnect queue entry (primary) and the secondary reconnect task now
keep the replaced adapter as "predecessor". The existing carry_inbound_dedup
seam reads its dedup caches from it before connect. A new
hand_over_held_inbound seam moves its held events at publish time
(_install_reconnected_adapter / secondary profile_map install). A candidate
that fails to connect never owns the queue, so no hand-back is needed, and
delivery waits until the replacement is registered. TelegramAdapter.
adopt_held_inbound rebinds only source._transport_adapter_ref (profile and
role authorization stay as resolved) and leaves a successor weakref on the
predecessor. Late _hold_inbound_event calls there (teardown salvage after the
replacement drained) forward to the replacement.
Reported-by: j2h4u (Maxim Brashenko)
Fixes#132829
Gate finding B01 #1 (2c + quality): since api_server (/v1) runs now ride
the cron drain floor, the docstrings and comments for
parse_cron_drain_timeout, _load_cron_drain_timeout,
_get_cron_drain_timeout, the DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT comment,
cli-config.yaml.example and the messaging guide still called it a
cron-only floor. Say it covers /v1 runs too, that 0 opts them out as
well, and why: an interrupted /v1 run fails its waiting caller.
Sibling sites of the api drain-floor fix: the 'waiting up to Ns' shutdown
log gated on cron only, so a longer wait held open for api_server runs
was silent; and the cron_drain_timeout default comment still described a
cron-only floor (with a duplicated #82161 paragraph). Log both counts and
describe the floor as covering cron jobs and api_server runs.
stop()'s drain grouped api_server (/v1) runs with chat turns on the
restart_drain_timeout budget, which defaults to 0. A default-config stop
therefore never entered the drain loop and hard-interrupted every
in-flight /v1 run after 0.00s, even though a caller is blocked on its
result and nothing resumes it. #82161 split only cron off onto its own
watchdog-clamped floor (agent.cron_drain_timeout).
Let api_server runs ride that same clamped floor (no new config key);
chat turns and deferred post-turn workers stay on the chat budget, and
cron_drain_timeout: 0 still opts out. Reuses the existing
_active_api_run_count counter via _drain_work_counts.
Fixes#132989. Diagnosis and the 'ride the cron deadline' approach by
the reporter, @ItoDonno.
Pure move (no behaviour change) so gateway/run_inbound.py, already over the
2,000-line file cap, shrinks instead of growing with the post-admission hook
call site. Logger name kept as "gateway.run" for log-record parity.
Teknium ruled the post-admission consumer hook (#129958) must fail OPEN, so
reshape the salvaged implementation into one small seam:
- `{"action": "handled", "reply": str|None}` is the only decisive result;
None, any other value, a raising callback or a timeout runs the ordinary
agent turn. One buggy plugin can no longer blackhole every inbound
message on a profile (the cherry-picked version failed closed on any
exception or malformed return from ANY registered callback).
- Fire under the message's ROUTED profile scope. The receiving bot's
handler binds the transport home so auth can read its .env; when that
bot serves a chat for another profile, the ambient scope at the hook was
the wrong profile and that profile's consumers never ran.
- Snapshot-only payload (session_key, platform, source dict, message_id,
text): no live gateway runner / session store handles, so the plugin
isolation host needs no HOST_DEGRADED_HOOKS entry.
- Bounded by plugins.hook_callback_timeout (fail-open) instead of the
fail-closed policy-hook set; the required-but-discarded receipt, alias
table, destination snapshot/restore machinery and hard-coded English
failure notice are gone.
- Tests trimmed to three invariants through the real _handle_message and
real plugin dispatch: consume path, raise path proceeds, routed profile
scope bound.
_restore_destination_if_mutated skipped the platform field, so a consumer
that rebound event.source.platform pinned the turn to the attacker's lane
(filtered subscriptions, allowance accounting). The snapshot stores the
enum's .value: coerce back to Platform on restore, with a ValueError
fallback. test_destination_mutation_is_restored now also rebinds platform
to TELEGRAM and asserts it returns to WHATSAPP (fails pre-fix).
The messaging gateway's stacked tokens come from the native (filesystem-only)
split, and filesystem commands win every collision in the interactive map, so
looking them up there loads the same skills. The table= override was a second
guard on a boundary the split already holds; the gateway test still goes red
when the native split resolves plugin tokens.
Also pass encoding= to the new tests' write_text calls (Windows footgun lint).
The messaging gateway's stacked skill rewrite resolved extra tokens and
loaded every stacked skill against the interactive map, so a trailing
/plugin:skill token loaded a plugin skill body on a native surface and
the per-platform disabled guard silently passed for it (the interactive
map's entries were invisible to the filesystem map's .get). Both the
token scan (interactive=False) and the stacked loader (table=) now run
against the same native map the first token uses: plugin skills are
interactive-only and a stacked /plugin:skill token resolves as unknown
instead of loading. Regression-tested at the gateway call-site
semantics and on the resolver/loader contract.
An agent working in an area gets root + that area's AGENTS.md as one project-context
block, so the area files must leave room beside the root. hermes_cli (24.2k -> 18.0k):
update-pipeline stage contract and free-tier long form -> cli-internals.md. gateway
(20.9k -> 17.2k): background notifications and /login long form -> gateway-internals.md.
apps/desktop (16.3k -> 9.4k): five judgment sections summarized with every invariant
kept, the reasoning -> new apps/desktop/ENGINEERING.md, the onboarding rehearsal ->
BUILDING.md.
The left-core migration decided "this home used Home Assistant" with its own config reader: the
first platforms.homeassistant block in config.yaml. The pre-extraction gateway enabled the adapter
from more places: the legacy gateway.json base layer, the gateway.homeassistant shorthand, and
nested gateway.platforms + top-level platforms blocks merged key by key. Those homes got no plugin
and no notice, and lost Home Assistant on update.
platform_configured_on() now asks the gateway loader itself: load_legacy_gateway_json +
read_yaml_layers (managed overlay, ${VAR} expansion) + merge_platform_sections, then
PlatformConfig.from_dict for the enabled flag, so the last explicit `enabled` keeps winning
(config.yaml enabled: false over a gateway.json enable stays not-in-use).
merge_platform_sections takes `also=` so the gateway.<platform> shorthand of a platform that
left core counts before its plugin registers (Platform() no longer knows the name).
Gateway start ran the left-core migration only for the launch home, so a secondary profile that
used Home Assistant as its event platform started without the plugin and without its adapter (and a
named launcher's default-root primary was never migrated). Each secondary now runs the migration in
its own runtime scope before its plugins are discovered, and gateway start covers the launch home and
the default root (the multiplex primary). The gateway-start helpers live in gateway/run_startup.py;
run.py and agent_init.py lose the try/except wrappers, since recover_at_startup never raises.
An agent-start install of a row that ships a platform now tells the user to restart the gateway
when one serves the home: the running gateway loaded its platforms before the plugin existed.
PluginContext.register_platform is last-writer-wins, so a user plugin could re-register 'telegram'
with trusted_inbound=True and every sender would skip the allowlist and pairing. The flag is now
refused for a platform core ships (a static Platform member or a bundled plugins/platforms/ plugin)
unless the registering plugin is bundled. Home Assistant is no longer either, so its catalog plugin
keeps it. `hermes plugins validate` prints a line for every trusted_inbound platform (and fails one
naming a core platform).
Also: the MCP collision test docstring no longer refers to the toolset it used to collide with.
The Home Assistant gateway platform (plugins/platforms/homeassistant) and the
homeassistant toolset (tools/homeassistant_tool.py) now ship as the official
catalog plugin `homeassistant` (NousResearch/hermes-homeassistant), with the
same platform name, toolset/tool names, config keys and env vars.
Migration (hermes_cli/left_core_migration.py, a generic "left core" table
reusing the memory-provider migration's helpers): every profile home that
used Home Assistant (HASS_TOKEN in its .env, platforms.homeassistant enabled
or holding a token, or the homeassistant toolset selected) gets the plugin
installed from the catalog at its reviewed pin by `hermes update`, and lazily
once per process at agent start / gateway start (honours
security.allow_lazy_installs). Every outcome reaches terminal, Desktop and
chat; a gateway-start outcome is handed to that home's first agent.
Core HA special cases become generic PlatformEntry seams the plugin uses:
trusted_inbound (allowlist bypass for event-bus platforms), display_tier
(built-in display defaults) and shared_env_prefixes (profile-clone ownership
rule for env shared with the plugin's tools). Cron delivery uses the existing
cron_deliver_env_var seam; HASS_TOKEN stays stripped from subprocesses via the
installed plugin's manifest secrets.
This is the same bug class as #132516. When a native clarify card fails
definitively, the runner retries with the base numbered-text
send_clarify, and that goes out through adapter.send(). In Telegram's
default "important" mode the retry carried only the thread metadata, so
_notification_kwargs sent the question with disable_notification=True.
The user got no push and the clarify wait timed out unanswered.
Tag the fallback with is_approval_prompt, the human-decision marker
_notification_kwargs now honours. text_fallback_coro only runs for
adapters that override send_clarify natively (Telegram, Discord, Slack,
...). The only other reader of the marker is WeCom, and WeCom keeps the
base send_clarify, so no other adapter changes.
The salvaged change also put notify=True on both approval sends.
On A2A, `notify` marks the turn-final reply, and A2A has no native
approval renderer. A prompt that needs a human decision would therefore
complete the caller's task with the prompt text while the turn is still
waiting (@ehz0ah's review on #132527). Telegram already pushes the
prompt through the approval-aware _notification_kwargs, so only the
is_approval_prompt marker is needed. The card send goes back to the
verbatim thread metadata: Telegram's control-message path never silences
cards.
Tests: the runner test pins that the fallback metadata stays interim and
does not carry notify. The adapter tests become a single parametrize.
Fold the _notification_kwargs change back into the original one-line
shape, so adapter.py grows by one line instead of six.
In the default Telegram notification mode ("important") the plain-text
approval fallback marked only is_approval_prompt, which no notification
decision consults, so _notification_kwargs sent the prompt with
disable_notification=True: the phone got no push, the user never learned
a dangerous command was waiting, and the tool sat the full approvals.timeout
(default 300s) before failing closed (#132516).
Mirror the cron-delivery convention on both approval sends in
_approval_notify_sync: notify=True on the button card and the text
fallback, and make _notification_kwargs itself approval-aware
(is_approval_prompt defeats disable_notification) so every surface that
marks the prompt is covered in one place. Loosens no guard and
auto-approves nothing.
(cherry picked from commit 576fec2ab6a6cdca08f96968574ba741c47568c0)
r2 2c Low: the timed-out own-gateway probe was raised to WARNING with no
dedupe, so a permanently wedged pipe peer logged it on every 30 s watcher
cycle and every control-socket rescan, indefinitely. Carry timed-out names
across passes in _profile_probe_timeout_warned (like the sibling
_profile_own_gateway_warned) and log repeats at DEBUG; a profile whose
probe recovers drops out of the set, so a later stall warns again.
The stalled-probe branch set a -1 sentinel that fell into the "own gateway"
path: it warned "Profile '%s' still runs its own gateway" (sending the
operator after a process that may not exist — the stall can be a busy
housekeeping pool) and recorded the name in _profile_own_gateway_warned,
which then suppressed the warning if a real own gateway appeared next cycle.
A timeout now logs its own warning, skips the profile this cycle and stays
out of the blocked/warned set. Gate r1 2c finding (Low).
`_query_windows_pipe` reads a synchronous pipe handle that has no settimeout,
so `_read_response_line`'s deadline is only checked between chunks: a peer
that accepts and never answers (a wedged gateway, or this gateway dialling its
own pipe from its own loop thread, #132547) blocks the caller forever. The
POSIX twin is already bounded by sock.settimeout and degrades to None.
Run the pipe exchange through the existing agent.deadline.run_bounded_sync so
every Windows caller of query_gateway_control (identify, pause, rescan, serve,
...) gets the same `timeout` bound and falls back to the scan layer on expiry.
A timed-out exchange abandons one daemon thread until the pipe read returns;
that is bounded by the number of stalled dials, not per-item hot loops.
Refs #132547.
reconcile_served_profiles called the own-gateway liveness probe inline on
the gateway event loop; the probe can end in a control-socket read with
no timeout of its own (a Windows named pipe stalls there until its peer
answers), parking shutdown_watchdog liveness probes until the multiplexer
is hard-killed with exit 75. The probe now runs on the bounded
housekeeping pool under a 5s bound; an unprovable profile reads as
blocked for the cycle (the multiplexer never races a possibly-live own
gateway) and the next cycle probes again.
(cherry picked from commit 84209e2f1a3bcddbc8b1e0123df70b02448d3f7a)
Skill Sync only worked against a Nous Portal sync plane that was never made
publicly accessible, so no install has ever used it. We don't want it in core
right now, so this deletes it outright instead of moving it behind a plugin
hook surface nobody needs yet:
- `hermes sync` command, `tools/skills_sync_client*.py` and their tests
- the personal pull ticks (gateway housekeeping, classic CLI startup,
`hermes serve` maintenance) and the debounced post-write push
- the per-skill `sync` usage flag (`set_sync` / `is_sync_enabled`)
- the `skills/_org/<org_id>/` mirror: index gating on `.active_org`, the
`[org-shared]` / name-collision labels, the org provenance header and
`org_provenance` field on skill_view, the org delete guard and the
auto-propose `org_sharing` note on skill_manage
The code is preserved in the private NousResearch/hermes-skill-sync repo if
the feature comes back.
A secondary treated a bound port as its own whenever its pidfile named a
live bridge, without checking that bridge listened on that port. After a
crash left the old bridge alive and bridge_port was pointed at a port
another profile's bridge held, the secondary probed and adopted the
foreign bridge (script hash and read-receipt config do not distinguish
phones). bridge.pid now records the spawn port on line 3; ownership
requires pid, start time and port to match. Pidfiles without a port fail
closed for secondaries; the default path reads lines 1-2 as before.
A profile that recorded a port as a secondary and later launched its own
gateway started its bridge on 3000 while `whatsapp send` read the record.
The launch adapter now resolves its port like standalone send (explicit,
then record, then 3000), so profiles without a record are unchanged.
gateway/AGENTS.md no longer lists the WhatsApp bridge as default-only
shared ingress.
Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
febd2af391 skipped WhatsApp on every secondary profile under multiplex on the
premise that "a secondary has no credential of its own to bring" because the
bridge is "one authenticated session tied to a single phone number". The
session directory is home-relative (`platforms/whatsapp/session` under the
profile's own home), so each profile already owns its own paired session.
What actually collided was the bridge port, 3000 for everyone, and the
adapter's startup path that kills whatever holds that port before spawning.
A fleet running one phone per profile lost WhatsApp on every profile but the
launching one, with a single INFO line to say so.
The credential gate for WhatsApp is now the profile's own paired session
(`creds.json` in its session directory), evaluated in that profile's scope.
An unpaired secondary is reported as `whatsapp_unpaired` with the pairing
command, once, no retry loop. A paired secondary gets an adapter of its own.
Port ownership without any probe of another profile's bridge
(`plugins/platforms/whatsapp/bridge_ownership.py`): an explicit
`platforms.whatsapp.extra.bridge_port` wins; otherwise a secondary takes the
first free port in 3001..3099 and records it in
`platforms/whatsapp/bridge_port` so it is stable across restarts. A
secondary never calls `_kill_port_process`, never adopts a running bridge
and never signals a pidfile process it did not spawn; a bound port or a
live prior process is a fatal for that profile only, naming the port and
the config key. The default profile's startup path is unchanged.
Out-of-process sends (`hermes -p X whatsapp send`) read the same record so
they reach the profile's own bridge.
Relay stays process-level shared ingress; its skip and remedy are untouched.
(cherry picked from commit f96c53df65cff45af2488bd13f22225ce1aec743)