5212 Commits

Author SHA1 Message Date
liuhao1024
fa34336c92 fix(gateway): resolve the launchd drain cap's job label through the wrapper
Under the generated launchd plist the stderr-timestamp wrapper is the job
process and the gateway is a grandchild: launchd stamps XPC_SERVICE_NAME
only on the wrapper, so the gateway reads "0" there, launchd_service_label()
returns None, and the ExitTimeOut drain cap (aa0289f307) silently never
applies — the exact SIGKILL-mid-teardown shape it was added to prevent.

The wrapper now re-exports its ai.hermes.* job label to the child as
HERMES_LAUNCHD_LABEL, and launchd_service_label() falls back to it when
XPC_SERVICE_NAME carries no usable label. Foreground/unsupervised starts
(no label to export) and app-coalition labels keep failing open exactly as
before.

Fixes #119598
2026-09-23 07:48:32 -07:00
ethernet
3176602021 Merge remote-tracking branch 'origin/main' into ethie/pm-clean 2026-09-23 10:48:07 -04:00
ethernet
197a078f72 Merge remote-tracking branch 'origin/main' into ethie/pm-clean 2026-09-23 10:43:56 -04:00
ethernet
c6b358592d Merge origin/main into ethie/pm-clean 2026-09-23 10:43:45 -04:00
Austin Pickett
d2ef7db751 fix(gateway): one served profile's failing chore no longer skips the rest (#120268)
_for_each_served_profile ran a housekeeping body once per served profile
with no boundary between them, and _housekeeping_chore only catches at the
tick level. One profile's unreadable store or broken .env therefore ended
the loop, and every profile after it lost its state.db archive/prune,
curator, skill-sync and MCP reconcile pass on every tick.

That state is reachable: _init_session_db tolerates a failed launch store
and keeps running, and hermes serve defers each served profile's
auto-archive to this loop (#117746), so a satellite behind a broken launch
store had no sweeper at all.

Each profile now gets its own try/except, logged at debug like
_housekeeping_chore.

Co-authored-by: Baris Sencan <b.sencan@equalsmoney.com>
2026-09-23 10:19:39 -04:00
Austin Pickett
bd945ec384 fix(state): surface corrupt state.db as one degraded session-storage state (#120274)
* fix(state): publish structural state.db corruption as one profile-level state

A structurally corrupt state.db showed up differently on every surface: the
sidebar endpoint returned 200 with empty slices plus an errors row, /api/sessions
returned 500, /api/status said components.storage ok and readiness was green.
None of them said the store was damaged, so Desktop rendered it as deleted
history (#72046).

hermes_state_health is now the single latch, keyed by resolved state.db path:

- SessionDB._halt_db_corrupt, the SessionDB read helpers, the web profile
  reader and the readiness probe publish into it, only for structural
  corruption (not FTS-scoped damage, not the malformed-schema case the web
  open path heals).
- gateway.readiness reports it (state_db degraded/corrupt, and session_store
  unavailable/corrupt even when the handle cache says ok), which also feeds
  /api/status components.storage (now with reason: corrupt).
- /api/sessions, /api/profiles/sessions and /api/profiles/sessions/sidebar
  carry storage: {profile: "corrupt"}; /api/sessions returns 503
  state_db_corrupt instead of 500.
- A peer SessionDB handle in the same process refuses writes on a latched
  path with the existing StateDbCorruptError, so gateway/agent transcript
  diversion and classify_persistence_error keep working unchanged.

The latch never clears on its own and resets on restart, the recovery boundary
StateDbCorruptError already documents.

Co-authored-by: konsisumer <konsisumer@users.noreply.github.com>

* fix(desktop): say the session store is damaged instead of an empty sidebar

The sidebar reads the list endpoints' new storage map into
$corruptSessionStores and renders a persistent destructive Alert above the
session list naming the affected profile(s). The copy says missing chats were
not deleted and points at the non-destructive path (quit Hermes, then
`hermes sessions recover --source <state.db> --inspect-only` or restore a
snapshot) plus the recovery guide; it does not recommend `sessions repair`
for structural damage.

Co-authored-by: konsisumer <konsisumer@users.noreply.github.com>

---------

Co-authored-by: konsisumer <konsisumer@users.noreply.github.com>
2026-09-23 10:17:46 -04:00
ethernet
f4a38b1ee8 Merge origin/main into ethie/pm-clean 2026-09-23 10:13:26 -04:00
teknium1
746f7ea21b fix(dashboard): Desktop children publish their own host role; SSH spawn shape is Desktop-owned
Review follow-up on the host-rendezvous isolation:

* Excluding Desktop-owned children from ROLE_SERVE broke #119644's terminal
  path: `hermes plugins install` found "the running Desktop backend" only via
  read_record(ROLE_SERVE), so on a Desktop-only box open chats no longer lit
  up. A Desktop child now publishes ROLE_DESKTOP_SERVE (own lock, record and
  0600 token). The attach/refuse ladder (_host_backend_attachment) still reads
  ROLE_SERVE only, so a supervised public dashboard is never blocked by it;
  notify_serve_backend prefers the host owner and falls back to the Desktop
  record. /api/host/identity reports the role actually published.

* is_desktop_owned_backend() missed Desktop's SSH spawn — `env HERMES_DESKTOP=1
  hermes serve --isolated --ssh-session-token-file F` carries NO token env var
  (remote-lifecycle tests assert the var name never appears) — so the SSH child
  still claimed ROLE_SERVE on the remote host and its MCP discovery flipped to
  deferred. The predicate now accepts the token-FILE argv shape; the argv half
  lives in _startup_fast.is_desktop_ssh_backend_argv (stdlib-only, importable
  before main.py's import wall) and replaces the two duplicate substring checks
  in main.py and dashboard_procs.py.

* web_server.py's remaining three bare HERMES_DESKTOP reads (cron ticker,
  managed-gateway teardown, orphan serve reap) route through the predicate;
  the tests that modelled a Desktop child with the bare flag set the spawn
  token, as a real pool child does.
2026-09-23 07:09:45 -07:00
beardthelion
0cbe552888 fix(scope): failed profile-scoped secret reads must not borrow os.environ
Eight secret readers wrapped the scoped get_secret() call in a broad
except Exception / contextlib.suppress and fell through to os.environ.
Under multiplex that env holds the default profile's value, so a bound
scope whose resolution fails silently borrowed another profile's
credential: the pairing allowlist reader could then persist the foreign
list into the served profile's .env, and the proxy key, tool gateway
token, OpenRouter and aux provider keys, ElevenLabs key, and Slack token
probe had the same shape.

Keep the deliberate UnscopedSecretError -> os.environ fallback (the
unscoped default-profile path legitimately reads its own env) and let
every other scoped-read failure propagate; the two availability probes
fail closed instead. config._scoped_environ_get now propagates as its
docstring already claimed.
2026-09-23 06:46:02 -07:00
beardthelion
6aaa1c480b fix(profile-scope): bind home and secret tokens inside the try that resets them
Several profile-scope binders called set_hermes_home_override and
set_secret_scope before the try/finally that releases them. A raise in
scope setup (a corrupt or removed profile home) propagated with the
foreign override still bound to the caller's context, silently re-homing
every later read — and in _reregister_orphaned_adopters it also skipped
every remaining adopter. The set calls now run inside the try with
None-guarded resets; the same shape is fixed in the routed-turn scope,
the cron external worker (which also leaked the multiplex flag), the
kanban worker scope, the MCP OAuth paths, the launch-profile policy,
and model_switch, which releases partially-bound scopes on raise.
2026-09-23 06:46:02 -07:00
beardthelion
2c3a75beaa fix(secret-scope): scoped misses fail closed under a foreign-home scope
get_secret returned os.environ on a scoped miss whenever multiplex was
off, but non-multiplex hosts serve foreign homes too (dashboard/desktop
backend, per-profile cron, MCP owner scopes, kanban spawn-env builds),
where os.environ is the launch profile's. Bound scopes now carry the
home they were built for; serves_routed_profile detects a foreign scope
even when the binder deliberately skips the HERMES_HOME override, and
the miss returns the caller's default. Every production binder stamps
its home; own-home scopes keep the deliberate env overlay.
2026-09-23 06:46:02 -07:00
KoNit-K
5b635064b8 fix(gateway): slash dispatch binds the routed profile's scope once for every handler
Under multiplex the inbound handler runs under the RECEIVING bot's profile
scope (authorization needs its .env), which is not the routed RUNTIME when a
bot serves another profile's chat (profile_routes / bot_profile), and the
handler's own scope is already gone by the time a `_track_background_task`
or busy-path dispatch fires. `/memory pending|approve|reject` and
`/skills ...` then read tools/write_approval's `get_hermes_home()/pending`,
`load_on_disk_store()` and the approval-gate config under the wrong home:
a served profile's bot answers "No pending memory writes" while its own
pending/memory/ holds records, and an approve applies to another profile's
memories/ (#119915).

Fix the class at the dispatcher seam instead of per handler: both slash
dispatch tables (`_hm_dispatch_canonical_command` idle path,
`_dispatch_busy_slash_command` busy path) enter
`_async_profile_scope_for_source(source)` — the async twin of the turn
scope `_profile_scope_for_source`, launch-profile scope when multiplexing
is off — around the handler call. Every table handler (memory, skills,
model, reasoning, compress, ...) now sees the runtime home; the per-handler
wraps in /compress, /profile and /model become redundant and can be removed
in a follow-up once their own tests are re-keyed.

Salvaged from #119922 (KoNit-K: bind the routed home around all /memory
and /skills review ops incl. the approval-gate check); the handler-local
wraps moved to the dispatcher. Supersedes #119932.

Co-authored-by: chugiacan <328579510+chugiacan@users.noreply.github.com>
2026-09-23 06:31:43 -07:00
teknium1
408687dd49 fix(gateway): /resume clears the persisted /model pin it used to drop by accident
With switch_session now carrying model_override across the re-point, /resume
- a real conversation boundary (#10702) - relied on the drop as a side effect.
_clear_conversation_scope clears only in-memory state, so the next turn would
rehydrate the persisted pin from the routing store. Clear it explicitly beside
the funnel call.

Invariant tests: switch_session keeps the pin; reset_session (/new) still drops it.
2026-09-23 06:31:43 -07:00
KoNit-K
8e9b3e00d2 fix(gateway): switch_session keeps the route's persisted /model pin
SessionStore.switch_session re-points a session key at another session id
for non-boundary reasons too (async-delegation re-pin, compression-tip
binding heal, CLI handoff, /branch), but _replace_route_locked built the
new SessionEntry without model_override, so the user's /model pin silently
reset to None on every such re-point (#119864).

Scoped to switch_session's kwargs, not the shared helper: reset_session
(/new) uses the same helper and is a deliberate boundary (30e947e0a0) that
must keep dropping the persisted pin, or the next turn's
_rehydrate_session_model_override resurrects it.

Salvaged from #119868 (moved from _replace_route_locked into switch_session).
2026-09-23 06:31:43 -07:00
John Paul Soliva
cb819d91c3 fix(gateway): run a chat /restart outside the requester's profile scope
Under multiplex, a message from a served profile's bot (or a default-bot
chat routed to a named profile) is handled inside that profile's
_async_profile_runtime_scope. /restart calls request_restart() from
there, and the bare asyncio.create_task() copied the handler's context,
so the whole restart orchestration kept the profile's HERMES_HOME
override, secret scope and terminal scope after the handler returned.

On a host without a service manager (shell/tmux runs, WSL, Termux, the
Windows Scheduled Task launcher) that task spawns the detached watcher,
whose env comes from build_subprocess_env(inherit_profile_home=True), so
it relaunched `hermes gateway restart` with HERMES_HOME set to the named
profile. The named-profile guard refuses that with exit 78, and the host
gateway never came back for any profile. On service-managed hosts the
same leaked scope sent stop()'s pending-message flushes to
<profile>/pending_messages, which boot recovery never reads.

Spawn the restart task in an empty Context, the same isolation
_spawn_supervised already uses for host-level tasks, so the watcher env,
stop() and its flushes all resolve the launch home.
2026-09-23 06:31:43 -07:00
John Paul Soliva
516687d7e3 fix(gateway): --replace starts beside another profile's standalone owner
host_attach.decide() returned REPLACE_HOST for any live host owner whenever
--replace was passed, before checking whether that owner serves this profile.
On a one-process-per-profile fleet the owner is another profile's standalone
gateway: _replace_target_belongs_to_other_profile correctly refuses to signal
it (fail closed), the gateway exits, and the supervisor restarts it into the
same refusal. hermes gateway install generates --replace for every unit, so
every profile but the one holding the host lock respawn-storms.

The non-replace path already handles this owner by starting beside it; only
--replace skipped that branch. --replace now targets the owner only when it
serves this profile, or when its served set is not known yet (the boot race,
where replacing keeps --replace's authority and the ownership guard still
decides). An owner known not to serve us takes the non-replace path.

Reproduced on a live macOS launchd fleet after updating to 0.21.4: the first
profile to restart claimed the host lock, and the default profile's unit then
looped on "Refusing --replace: PID <other profile's gateway> cannot be proven
to belong to this profile's gateway" until the respawn-storm breaker engaged.
2026-09-23 06:27:24 -07:00
John Paul Soliva
ae163160fe fix(gateway): --replace no longer skips the host-lock refusal
The host lock is the only arbiter of two gateways starting together (the
attach check reads a record published a moment after the owner's claim),
and its loser exits 75 so the supervisor retries into attach. The PID
claim passed `force or replace`, so `--replace` skipped that refusal too.
Every unit Hermes generates runs `gateway run --replace`, which turned
the arbiter off for exactly the race it exists for: a unit that saw no
owner yet started a second gateway beside the multiplexer, and the two
fought over the same bot tokens (a --replace token handoff SIGTERMs the
holder).

Only --force skips it now. A --replace that took the owner over already
freed the lock with that process, so real takeovers are unchanged.
2026-09-23 06:27:24 -07:00
teknium1
3532a60bfa fix(gateway): scoped liveness never borrows another home's record or the multiplexer roster
Slim redo of the copied-profile-dir guard (#119772) at the seams that actually leaked on main:

- get_runtime_status_running_pid(expected_home=...) now rejects a record whose hermes_home
  stamp names another home (rung 3 of resolve_gateway_liveness and every direct caller:
  live_gateway_pid_for_home, the dashboard messaging/status readers, the update inventory).
  Rung 1 already applied recorded_gateway_home_conflicts inside get_running_pid.
- A scoped read with no gateway_state.json hands rung 3 an empty record, never None -- None
  re-read the PROCESS home's record and lent its live PID to the copied directory.
- multiplexer_liveness_for_profile only answers for <default root>/profiles/<name>: the roster
  is matched by name and a same-named directory under another root is not the served home.

Two invariant tests replace the PR's four (same contract, real records instead of probe stubs).
2026-09-23 06:24:19 -07:00
kuzi.
0df9c8eff1 fix(gateway): never report a copied profile dir as running 2026-09-23 06:24:19 -07:00
KoNit-K
8482aa0716 fix(gateway): bridge webhook env settings for config enablement 2026-09-23 06:03:10 -07:00
Konstantin Khlopkov
06cafaa7b3 fix(gateway): expand ${VAR} env refs in platform adapter config
The gateway's platform-config loader (read_yaml_layers) returned the raw
config.yaml tree, so ${VAR}/${env:VAR} references under platforms: reached
adapters as literal strings - the webhook HMAC secret, api_server key and
teams credentials never resolved, while the CLI loader path (load_config)
expanded the same file. Apply _expand_env_vars to the user layer before the
managed overlay - the same order config_effective._effective established.

Fixes #119733
2026-09-23 06:03:10 -07:00
teknium1
332159e79f fix(gateway): a planned stop stays planned when the watcher beats the CLI's SIGTERM
`hermes gateway stop` writes the planned-stop marker, then sends SIGTERM.
The gateway's planned-stop watcher (0.5 s poll) can fire in between: its
shutdown call consumes the marker as planned, and the CLI's SIGTERM that
follows finds no marker, is classified as an external kill and the gateway
exits 1 — which Restart=on-failure supervisors answer by reviving a gateway
the operator just stopped. Remember an accepted planned stop in the handler
so the trailing SIGTERM of the same stop is treated as planned.

Found by the core parity E2E matrix (gateway/api_server graceful_exit
cell), reproduced deterministically by signalling after the watcher has
consumed the marker.
2026-09-23 06:00:06 -07:00
ethernet
ec8c1ac79a Merge remote-tracking branch 'origin/main' into ethie/pm-clean 2026-09-23 08:48:33 -04:00
teknium1
eb8960fead fix(api_server): keep one memory provider per session across requests (#120116)
The api_server platform builds a fresh AIAgent per request (per-request
callbacks, model route, ephemeral prompt), so the memory provider was
re-initialised on every request. External providers deliver recall as the
PREVIOUS turn's background prefetch held on the provider instance, so a
continued session (X-Hermes-Session-Id, previous_response_id, declared
session key) never received automatic recall, and for hindsight
local_embedded each init also restarted the embedded daemon, killing the
retain still in flight. Pre-existing: the same probe fails on main before
the hindsight catalog migration (526d135a96, bundled provider).

ApiServerMemorySessions parks the session's initialised MemoryManager
between requests (exclusive check-out/check-in, keyed by profile home +
session id, LRU/idle eviction under the owning profile's scope) and
AIAgent(memory_manager=...) adopts it instead of loading and initialising
the provider again. /v1/chat/completions, /v1/responses, session chat and
/v1/runs all go through the same two seams (_create_agent, turn finally).
2026-09-23 05:41:31 -07:00
ethernet
4cb2054801 Merge remote-tracking branch 'origin/main' into ethie/pm-clean 2026-09-23 07:48:31 -04:00
teknium1
07646a7f72 fix: a chat switched to Codex no longer sends its model to the previous provider's endpoint
The messaging gateway's /model wrote only model + provider to the session row. A row the
Desktop/TUI had persisted with the Nous Portal route kept that base_url and api_mode, so resuming
a chat switched to openai-codex built provider=openai-codex on the Portal URL and posted
gpt-6-luna-900k to inference-api.nousresearch.com/v1/chat/completions: "Model 'gpt-6-luna-900k'
isn't available on ChatGPT or Codex Subscription".

- SessionDB.update_session_model writes the whole route (provider, base_url, api_mode) in both
  shapes resume reads (top-level for TUI/Desktop, gateway_runtime for the CLI) whenever a
  provider is given; the gateway /model passes the switch result's endpoint.
- Resume readers (TUI/Desktop, CLI, gateway rehydrate) drop a persisted base_url that is another
  built-in provider's canonical endpoint, so rows already written by older builds heal.
- A gateway session override whose credentials failed to re-resolve is resolved for its own
  provider on the turn instead of being layered over the default provider's runtime (which
  produced openai-codex + Nous key + Nous URL). If it still cannot resolve, that turn runs on the
  whole default route with the existing one-shot "Provider fallback" notice; the override is kept
  and retried next turn.
2026-09-23 04:44:31 -07:00
ethernet
a43f8c2dd7 Merge remote-tracking branch 'origin/main' into ethie/pm-clean 2026-09-23 07:03:18 -04:00
ethernet
890bbbda1f Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts
#	apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts
#	apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts
#	apps/desktop/e2e/bot-mode-roster-localized.spec.ts
#	apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts
#	apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts
#	apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts
#	apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts
#	apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts
#	apps/desktop/e2e/bot-roster-user-sections.spec.ts
#	apps/desktop/e2e/bot-routines-pane-narrow.spec.ts
#	apps/desktop/e2e/bot-row-open-recent-session.spec.ts
#	apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts
#	apps/desktop/e2e/group-composer-auto-grow.spec.ts
#	apps/desktop/e2e/group-create-gate-remote-roster.spec.ts
#	apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts
#	apps/desktop/e2e/hosted-room-backend-continuity.spec.ts
#	apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts
#	apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts
#	apps/desktop/e2e/worktree-branch-status.spec.ts
#	apps/desktop/electron/backend-probes.test.ts
#	apps/desktop/electron/connection-apply.test.ts
#	apps/desktop/electron/desktop-electron-pin.test.ts
#	apps/desktop/electron/desktop-uninstall.test.ts
#	apps/desktop/electron/gateway-file-download-transport.test.ts
#	apps/desktop/electron/gateway-stop-before-update.test.ts
#	apps/desktop/electron/github-api-auth.test.ts
#	apps/desktop/electron/registry-primary-profile-scope.test.ts
#	apps/desktop/electron/update-api-check.test.ts
#	apps/desktop/electron/update-handoff-marker.test.ts
#	apps/desktop/electron/venv-blocker-scan.test.ts
#	apps/desktop/scripts/after-extract.test.mjs
#	apps/desktop/scripts/local-pack-publish.test.mjs
#	apps/desktop/scripts/tasks-scroll.test.mjs
#	apps/desktop/src/app/settings/model-settings.test.tsx
#	apps/desktop/src/app/updates-overlay.blockers.test.tsx
#	apps/desktop/src/components/desktop-install-overlay.test.tsx
#	apps/desktop/src/lib/update-copy.test.ts
#	scripts/ci/check_os_marker_fakes.py
#	tests-js/desktop-mac-usage-descriptions.test.ts
#	tests-js/node-engine-alignment.test.ts
#	tests/agent/lsp/test_install_and_lint_fixes.py
#	tests/agent/test_command_token_source.py
#	tests/agent/test_compression_boundary_hook.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/agent/test_custom_provider_ca_probes.py
#	tests/agent/test_endpoint_blackhole.py
#	tests/agent/test_estimator_parity.py
#	tests/agent/test_in_place_compaction.py
#	tests/agent/test_moa_loop_mode.py
#	tests/agent/test_model_metadata.py
#	tests/agent/test_skill_session_platform_gate.py
#	tests/agent/test_skill_utils.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/computer_use/test_doctor.py
#	tests/cron/test_codex_execution_paths.py
#	tests/cron/test_cron_bot_chat_delivery.py
#	tests/cron/test_cron_script.py
#	tests/cron/test_media_delivery_parity.py
#	tests/cron/test_misfire_catchup.py
#	tests/cron/test_parallel_pool.py
#	tests/cron/test_recurring_eagain_redispatch.py
#	tests/gateway/test_choice_picker.py
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_dingtalk.py
#	tests/gateway/test_feishu.py
#	tests/gateway/test_feishu_onboard.py
#	tests/gateway/test_gateway_shutdown.py
#	tests/gateway/test_matrix.py
#	tests/gateway/test_model_command_custom_providers.py
#	tests/gateway/test_reasoning_command.py
#	tests/gateway/test_runtime_footer.py
#	tests/gateway/test_session.py
#	tests/gateway/test_session_hygiene.py
#	tests/gateway/test_status.py
#	tests/gateway/test_teams.py
#	tests/gateway/test_turn_lease.py
#	tests/gateway/test_whatsapp_connect.py
#	tests/hermes_cli/test_approvals_command.py
#	tests/hermes_cli/test_auth_store_lock_concurrent.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_banner_git_state.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_compat_manifest_targets.py
#	tests/hermes_cli/test_computer_use_cli.py
#	tests/hermes_cli/test_cpr_local_leak.py
#	tests/hermes_cli/test_dashboard_auth_gate.py
#	tests/hermes_cli/test_dashboard_procs_kill_grace.py
#	tests/hermes_cli/test_desktop_lifecycle_windows_live.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_command_install.py
#	tests/hermes_cli/test_fleet_config_migration_windows_live.py
#	tests/hermes_cli/test_gateway.py
#	tests/hermes_cli/test_gateway_platform_gating.py
#	tests/hermes_cli/test_gateway_restart_loop.py
#	tests/hermes_cli/test_gateway_task_probe.py
#	tests/hermes_cli/test_gateway_wsl.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_install_cua_driver.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_command_exports.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_linux_desktop_entry.py
#	tests/hermes_cli/test_local_runtime.py
#	tests/hermes_cli/test_local_runtime_updates.py
#	tests/hermes_cli/test_managed_uv.py
#	tests/hermes_cli/test_mcp_reload_confirm_gate.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_npm_engine.py
#	tests/hermes_cli/test_personality_none.py
#	tests/hermes_cli/test_pet_toggle.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_plugin_event_bus.py
#	tests/hermes_cli/test_plugin_manifest_v2.py
#	tests/hermes_cli/test_plugin_packs.py
#	tests/hermes_cli/test_plugins_cmd.py
#	tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py
#	tests/hermes_cli/test_process_identity.py
#	tests/hermes_cli/test_profiles.py
#	tests/hermes_cli/test_profiles_sidebar_cache.py
#	tests/hermes_cli/test_pty_bridge.py
#	tests/hermes_cli/test_resolve_turn_limit.py
#	tests/hermes_cli/test_serve_runtime_inventory.py
#	tests/hermes_cli/test_session_vacuum_config.py
#	tests/hermes_cli/test_set_config_value.py
#	tests/hermes_cli/test_signal_handler_kanban_worker.py
#	tests/hermes_cli/test_slash_confirm_windows.py
#	tests/hermes_cli/test_stale_pid_guard.py
#	tests/hermes_cli/test_startup_fast_guards.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_telegram_managed_bot.py
#	tests/hermes_cli/test_tools_config.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_autostash.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_fetch_failure_classifier.py
#	tests/hermes_cli/test_update_fleet_probe_resume_token.py
#	tests/hermes_cli/test_update_handoff_backend_reap.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_host_obligation.py
#	tests/hermes_cli/test_update_import_guard.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_update_inventory.py
#	tests/hermes_cli/test_update_launchd_unloaded_gateway.py
#	tests/hermes_cli/test_update_missing_configured_deps.py
#	tests/hermes_cli/test_update_modified_notice.py
#	tests/hermes_cli/test_update_multiplex_migration_hook.py
#	tests/hermes_cli/test_update_no_gateway_restart.py
#	tests/hermes_cli/test_update_orphan_backend_reap.py
#	tests/hermes_cli/test_update_parked_branch_guard.py
#	tests/hermes_cli/test_update_post_pull_syntax_guard.py
#	tests/hermes_cli/test_update_receipt.py
#	tests/hermes_cli/test_update_self_lock.py
#	tests/hermes_cli/test_update_shim_fail_closed.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_update_sqlite_remediation.py
#	tests/hermes_cli/test_update_stale_dashboard.py
#	tests/hermes_cli/test_update_stale_virtualenv.py
#	tests/hermes_cli/test_update_venv_health.py
#	tests/hermes_cli/test_update_venv_ownership_preflight.py
#	tests/hermes_cli/test_update_wedged_gateway.py
#	tests/hermes_cli/test_update_yes_flag.py
#	tests/hermes_cli/test_update_zip_two_phase.py
#	tests/hermes_cli/test_urllib_security.py
#	tests/hermes_cli/test_ux_messages_auth_config.py
#	tests/hermes_cli/test_ux_messages_startup.py
#	tests/hermes_cli/test_venv_holder_classifier.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/hermes_cli/test_verify_core_dependencies.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_server_console_ws.py
#	tests/hermes_cli/test_web_server_ws_ping.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/hermes_state/test_fts_rebuild_admission.py
#	tests/hermes_state/test_hermes_state.py
#	tests/plugins/memory/test_memory_lazy_install.py
#	tests/plugins/test_google_meet_plugin.py
#	tests/plugins/test_langfuse_plugin.py
#	tests/plugins/test_security_guidance_plugin.py
#	tests/plugins/test_transform_llm_output_hook.py
#	tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_contributor_map.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/skills/test_competitor_news_monitor_skill.py
#	tests/skills/test_document_to_action_items_skill.py
#	tests/skills/test_google_workspace_setup.py
#	tests/skills/test_google_workspace_setup_deps.py
#	tests/skills/test_grounded_citations_skill.py
#	tests/skills/test_ip_as_logo_skill.py
#	tests/skills/test_live_dashboard_skill.py
#	tests/skills/test_mcp_oauth_remote_gateway_skill.py
#	tests/skills/test_office_document_skills.py
#	tests/skills/test_openclaw_migration.py
#	tests/skills/test_product_price_monitor_skill.py
#	tests/skills/test_scrollcraft_skill.py
#	tests/skills/test_setup_wizard_generator_skill.py
#	tests/skills/test_weekly_review_planning_skill.py
#	tests/test_engines_satisfiable.py
#	tests/test_fast_safe_load.py
#	tests/test_hermes_bootstrap.py
#	tests/test_hermes_constants.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_model_tools_async_bridge.py
#	tests/test_packaging_build_guard.py
#	tests/test_packaging_metadata.py
#	tests/test_yaml_indent_consistency.py
#	tests/tools/test_approval_timeout_overflow.py
#	tests/tools/test_base_environment.py
#	tests/tools/test_bot_mode_dm.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_hardening.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_clipboard.py
#	tests/tools/test_code_execution.py
#	tests/tools/test_code_execution_modes.py
#	tests/tools/test_code_execution_windows_env.py
#	tests/tools/test_computer_use.py
#	tests/tools/test_delegate_liveness_timeout.py
#	tests/tools/test_execute_code_approval_cluster.py
#	tests/tools/test_execution_flag_detection.py
#	tests/tools/test_fal_common.py
#	tests/tools/test_file_operations.py
#	tests/tools/test_file_tools.py
#	tests/tools/test_file_tools_cwd_resolution.py
#	tests/tools/test_file_tools_live.py
#	tests/tools/test_lazy_deps.py
#	tests/tools/test_lazy_deps_durable_target.py
#	tests/tools/test_lazy_deps_managed.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_local_tempdir.py
#	tests/tools/test_macos_protected_search.py
#	tests/tools/test_mcp_npx_cached_bin.py
#	tests/tools/test_oneshot_completion_linger.py
#	tests/tools/test_process_registry.py
#	tests/tools/test_read_file_schema_gating.py
#	tests/tools/test_skill_improvements.py
#	tests/tools/test_skills_sync.py
#	tests/tools/test_termux_api_detection.py
#	tests/tools/test_tirith_security.py
#	tests/tools/test_transcription_tools.py
#	tests/tools/test_tts_streaming.py
#	tests/tools/test_wake_word.py
#	tests/tui_gateway/test_compute_host_borrowed_lease.py
#	tests/tui_gateway/test_compute_host_turn_protocol.py
#	tests/tui_gateway/test_isolated_orphan_activity.py
#	tests/tui_gateway/test_protocol.py
#	tests/tui_gateway/test_slash_worker_profile_home.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	ui-tui/src/__tests__/terminalParity.test.ts
#	ui-tui/src/__tests__/termuxComposerLayout.test.ts
#	ui-tui/src/__tests__/textInputFastEcho.test.ts
2026-09-23 07:02:44 -04:00
beardthelion
3e2d5b66bb fix(gateway): allowlist no longer admits any user_id sharing a '@' localpart
_principal_matches_allowlist added user_id.split("@")[0] to the match set
for every platform. The split was written for WhatsApp JIDs, which are now
covered by the scoped alias expansion; on platforms whose user_id is an
email or iMessage handle (google_chat, bluebubbles, email) a bare allowlist
entry "alice" admitted alice@<any domain>. Only fully qualified ids match
outside the platform-scoped aliases.

(cherry picked from commit c583c59a55d04bdf327d85ca63d020276be5fbc5)
2026-09-23 16:20:19 +05:30
teknium1
73ba639092 fix(imessage): strip markdown on every Photon plain send and keep link URLs on BlueBubbles
Follow-up on the two salvaged commits:
- One _sidecar_payload_text() helper decides the /send text for both the adapter
  path and _standalone_send (cron / send_message), which had the same raw-markdown
  leak on URL-bearing messages and never stripped with PHOTON_MARKDOWN=false.
- BlueBubbles (same iMessage surface) keeps [label](url) targets as bare URLs.
- Tests trimmed to two invariants.
2026-09-23 03:10:15 -07:00
Eddie Wang
449ac2fb8f fix(photon): strip markdown when the sidecar downgrades to the text builder
chooseSendFormat() routes markdown containing a raw http(s) URL through
spectrum-ts' text() builder, because the markdown builder's iMessage data
detection 500s on those messages (#73615). text() ships the payload
verbatim, and nothing strips the markers on the way down, so any reply
that mentions a link arrives in iMessage as literal markdown source:

    **Release 1.2.0** is out
    - **EUR 5** off this month
    https://example.com/releases/1.2.0

Every ** is visible in the bubble. Remove the URL from that same reply
and it renders correctly, which is what makes the URL the trigger rather
than the content.

spectrum-ts documents markdown() as degrading to readable plain text on
platforms without native support "instead of surfacing raw ** markers".
Selecting text() ourselves opts out of that guarantee, so the adapter has
to honour it instead.

Strip in _sidecar_send() when the payload is markdown and the sidecar will
downgrade it. The format key is deliberately preserved: the sidecar owns
the builder choice (test_rich_links.py pins that contract), and an older
sidecar without chooseSendFormat must keep rendering natively.

_send_plain_fallback() selects the text builder explicitly via
markdown=False and had the same leak, so it strips too.

Reuses the shared strip_markdown() helper rather than adding a second
implementation, so the PHOTON_MARKDOWN=false path and this path produce
identical output and both inherit any future fix to that helper.

Diagnosis previously reported in #85733, which was closed unmerged.

Stripping must not take the URL with it, though. The shared helper
collapsed [label](url) to label alone, which on this path is worse than
raw markdown: iMessage auto-links bare URLs and nothing else, so the
reply arrives with a description and no way to reach the link.

    Open the itinerary on Google Flights     <- URL gone entirely

So strip_markdown() takes keep_link_targets, which rewrites
[label](https://url) as "label\nurl" (own line, because these URLs are
often long) and leaves non-http targets such as mailto: or relative
paths label-only, since iMessage won't linkify those either. The default
is unchanged, so the SMS, IRC, Feishu and QQ callers keep dropping the
target as before.

plugins/platforms/line/adapter.py already carries a private
strip_markdown_preserving_urls() for exactly this reason ("LINE
auto-links bare URLs only"). This moves the behaviour behind the shared
helper instead, so Photon's three plain-text paths -- the downgrade,
_send_plain_fallback(), and PHOTON_MARKDOWN=false -- all agree.

gateway/platforms/bluebubbles.py is the same iMessage surface with the
same loss; left alone here to keep this change to one platform.
2026-09-23 03:10:15 -07:00
ethernet
cbb9660c2c Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	hermes_cli/plugin_python_deps.py
2026-09-22 23:08:39 -04:00
teknium1
9fe737aef2 gateway: frame gateway.standalone as a TEMPORARY compatibility shim, not a kept topology
Multiplex-only is the direction. This key exists so fleets that lost per-profile gateways in
the switch keep working while the remaining gaps (per-profile stop/restart, WhatsApp
bridge/relay on secondaries, dashboard scoping) are closed, and it goes away once they are.
Every surface that names the key now says so through one shared notice
(STANDALONE_DEPRECATION_NOTICE): the install/start refusal hint, per-profile and default
`gateway status`, the migrate plan, and the host boot log (WARNING, not INFO). The user
guide carries a deprecation admonition where the key is introduced and no longer describes
it as opting out "for good".

Also: the Windows cold-start test fixture stubbed profiles_to_serve(multiplex) without the
new include_standalone kwarg (the PR's one red on CI).
2026-09-22 19:55:02 -07:00
Victor Kyriazakos
0238c9d740 feat(gateway): gateway.standalone opts a named profile out of the host multiplexer
A named profile that authors `gateway.standalone: true` in its own config.yaml
runs its own gateway again, the pre-multiplex topology, while the default
gateway keeps serving every other profile. Topology becomes something the
operator authors per profile instead of something the box infers from boot
state, which is what a fleet running per-profile gateways lost when
`gateway.multiplex_profiles: false` was retired.

Changed
- hermes_cli/profiles.py: `profile_is_standalone(home)` reads the profile's
  own config.yaml (memo by file signature, tolerant of malformed yaml, always
  False for the default profile with one warning). `profiles_to_serve()`
  excludes standalone profiles; roster callers that mean "every installed
  profile" (plugin deps, Windows update, launch policy, dashboard listing and
  topology) pass `include_standalone=True`.
- gateway/host_attach.py: `standalone_attach_decision` starts a standalone
  profile's gateway beside the host multiplexer once every live gateway
  confirms it does not serve that profile; refuses with a rescan message while
  one still does. Used by the initial attach check and the lock-losing race.
- hermes_cli/gateway_multiplex_mode.py: a standalone launcher never becomes
  the host multiplexer (`STANDALONE_PROFILE_REASON`), including callers that
  supply an explicit GatewayConfig.
- hermes_cli/gateway.py, web_server_gateway.py: `hermes -p X gateway
  install/start/run` proceeds without --force for a standalone profile; the
  refusal text for other profiles points at the opt-out; status shows
  "standalone (gateway.standalone: true)" and the default lists skipped
  profiles.
- gateway/run_profile_reconcile.py: the host does not re-adopt a profile whose
  own gateway is live (removing the key while it runs no longer double-binds).
- hermes_cli/gateway_migrate.py: standalone profiles are neither blocker nor
  fold target; the plan lists them as "standalone by config".
- gateway/run.py: one INFO line per standalone profile at host boot.

Tests: two-home E2E through real loaders and resolve_multiplex_mode, decide()
with fake host records for both arms, lock-losing branch, reconcile guard,
migrate plan, refusal predicate both ways, topology, memo and malformed-yaml
contracts. All red on base.
2026-09-22 19:55:02 -07:00
ethernet
4b803147d3 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/src/app/contrib/onboarding-kickoff.ts
#	hermes_cli/profiles.py
2026-09-22 17:16:06 -04:00
kshitijk4poor
e1c71bd4fc fix(gateway): legacy checkpoints: true signature carries the default limits, one cfg_get per key
#119406 made absent keys carry DEFAULT_CONFIG's value in the cache signature, but the
legacy bool branch still emitted None for max_snapshots/max_total_size_mb/max_file_size_mb
while `_checkpoint_agent_kwargs` builds that agent from DEFAULT_CONFIG — so migrating to
`checkpoints: {enabled: true}` rebuilt the cached agent for nothing. The dict/absent branches
were also re-implementing cfg_get's own semantics; one `cfg_get(cfg, section, key, default=…)`
covers absent section, non-dict section, absent key and explicit null. DEFAULT_CONFIG is
imported at module top (hermes_cli.config already is; gateway/restart.py sets the precedent).
2026-09-23 00:25:29 +05:30
kshitijk4poor
8eeec5423b fix(gateway): threshold_tokens: null rebuilds a cached agent instead of waiting for a restart
The agent-cache signature reads the raw config file (no DEFAULT_CONFIG merge) and mapped
an absent key to None. Since #115986 shipped compression.threshold_tokens=256K, the
documented opt-out (`hermes config set compression.threshold_tokens null`) produced the
same None as "never set", so the signature did not change and live gateway sessions
kept compacting at 256K until the process restarted (community report on #115986).

Absent keys now carry DEFAULT_CONFIG's value — what the agent was actually built with —
so absent == explicit default (no spurious rebuild) and absent != explicit null.
2026-09-23 00:10:27 +05:30
ethernet
1f48a3d036 Merge remote-tracking branch 'origin/main' into ethie/pm-clean 2026-09-22 13:03:25 -04:00
teknium1
21d0b12958 feat(plugins): late-loaded plugins wire their platform handlers live (#87770)
A plugin that finished loading after an adapter connected never got its platform
handlers (slash commands, button callbacks, inbound transforms) registered until a
gateway restart, silently. Three pieces, one seam shared by every surface:

1. Discovery listener: PluginManager.on_plugin_loaded(cb) fires from INSIDE
   discover_and_load for the plugins a sweep newly loaded (diff of the loaded set),
   with a per-plugin activation summary (hermes_cli/plugins_activation.py):
   activated_now {gateway_commands, gateway_transforms, hooks, callbacks} vs
   deferred {tools, prompt, mcp_servers}. Every mid-run load path now performs a real
   discover_plugins(force=True): CLI install/enable (via the gateway), Desktop/TUI
   plugins.manage install/toggle/update, dashboard REST install, tool-triggered
   force re-discovery, the new `reload-plugins` control-socket verb. A non-forced
   discover_plugins() short-circuits on _discovered, which is why reload.mcp after
   a mid-run install used to reload the OLD server set.
2. Idempotent re-wire: BasePlatformAdapter.rewire_plugin_handlers() runs only
   factories not yet wired on the live native client (keyed (plugin, qualname);
   a force reload hands back new function objects). Telegram hoists late handlers
   ahead of core's catch-all filters.COMMAND / CallbackQueryHandler (PTB dispatches
   the first match per group) and re-wires on the transient-init rebuild; Slack
   dedupes register_slack_action_handler per AsyncApp. The gateway runner
   subscribes per served profile and re-wires on the loop.
3. Scope limit + honest messaging: handlers only. Tools/prompt stay deferred to
   the next session (prompt-cache invariant), MCP servers to mcp.reload; the CLI
   hint and plugins.manage results (activation, gateway_reloaded,
   restart_required only when no gateway answered) say exactly that.
2026-09-22 09:50:22 -07:00
ethernet
a49d196b5c Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	hermes_cli/env_loader.py
#	hermes_cli/urllib_security.py
#	tools/terminal_scope.py
2026-09-22 07:05:11 -04:00
kshitijk4poor
d8a515a540 fix(gateway): the shared profile-scope key is the Path _profile_runtime_scope always received
6c02d592d4 stringified the resolved home before handing it to
_profile_runtime_scope; callers and tests
(test_multiplex_background_task_scope) contract on the Path. Return the
Path itself — it is hashable, so the heartbeat-restore grouping keys on
it unchanged.

PROOF: tests/gateway/test_multiplex_background_task_scope.py red before
(expected call(PosixPath('/fake/profile')), actual call('/fake/profile')),
green after; test_heartbeat_watch_restore.py 4 passed.
2026-09-22 16:15:34 +05:30
kshitijk4poor
1e643524cb refactor(gateway): share the profile-scope key between scope entry and heartbeat restore
`gateway/run_heartbeat_restore.py::_scope_key` re-implemented the branch
condition of `GatewayRunner._profile_scope_for_source` (multiplex check +
`_resolve_profile_home_for_source`). If the standalone branch ever became
source-dependent the restore grouping would silently merge sessions that
need distinct scopes. Add `GatewayRunner._profile_scope_key_for_source`
(home-key string, or None for the standalone launch scope) as the single
owner of that condition; `_profile_scope_for_source` and the restore
grouping both call it. Behaviour-neutral.

PROOF: `grep -n _profile_scope_key_for_source gateway/run_turn.py
gateway/run_heartbeat_restore.py` shows the definition plus both callers
and `_scope_key` is gone (asserted by real import); `scripts/run_tests.sh
tests/gateway/test_heartbeat_watch_restore.py` 4 passed (its
`_profile_scope_for_source` counter still sees one entry per scope);
ruff + check-windows-footguns clean.
2026-09-22 16:15:34 +05:30
carlotestor
3e88c8032d perf(gateway): enter each profile scope once per heartbeat-restore scan; signature-cache profile config.yaml parses
restore_heartbeat_watches entered _profile_scope_for_source for every routed
session on every poll. Each entry hydrated the profile secret scope and rebuilt
the terminal policy, and both re-parsed the profile config.yaml from disk, so N
routed sessions cost 2N YAML parses per poll even though nothing changed.

- Group entries by resolved profile home and enter the scope once per group.
- Add utils.load_yaml_file_readonly (file_signature-keyed cache) and use it in
  env_loader._load_secrets_config and terminal_scope.build_profile_terminal_scope,
  which were both open()+fast_safe_load per scope entry. Present-but-unparseable
  still fails closed: parse errors propagate and are never cached.

Measured on a 3-profile host: one _profile_runtime_scope enter/exit 1.80 ms -> 0.11 ms.

(cherry picked from commit c6b16629bd38799bbf166206c73a6140a9559a61)
2026-09-22 16:15:34 +05:30
kshitijk4poor
4607e5017c refactor(gateway): drop the test-only delivery-ledger _prune() wrapper
After 39e914ea33 the prune runs inside `record_obligation`'s transaction
via `_prune_unlocked`; `_prune()` had a single caller left, the test
`TestPrune::test_old_delivered_rows_pruned`. It also opened its own
transaction WITHOUT `_DB_LOCK` while every other writer in the module
takes `_DB_LOCK, _transaction()`, so the `_unlocked` contract ("caller
holds the lock") was violated by its only wrapper.

Delete it and have the test do what a writer does:
`with dl._DB_LOCK, dl._transaction() as conn: dl._prune_unlocked(conn, now)`.

`grep -rn '_prune(' --include=*.py gateway tests/gateway`: no
production caller (gateway/run.py's hit is `_housekeeping_checkpoint_prune`).

PROOF: tests/gateway/test_delivery_ledger.py (TestPrune included) +
test_delivery_ledger_single_connection.py: 40 passed.
2026-09-22 15:55:09 +05:30
kshitijk4poor
b255c8fb15 fix(gateway): a prune error inside the recording transaction is not swallowed
The try/except → DEBUG log around `_prune_unlocked` claimed "a prune
failure must never cost the recorded obligation". Inside
`sqlite_util.transaction()` (`with conn:`) that is only true for an
ordinary statement error; on SQLITE_FULL / IOERR / NOMEM the whole
transaction is rolled back, the INSERT is lost, and the only trace is a
DEBUG line. Before 39e914ea33 the insert had already committed on its
own connection when `_prune()` ran, so the comment described the old
shape, not this one.

Call it bare, matching the cron siblings (cron/executions.py,
cron/delivery_queue.py): a failing prune now surfaces like any other
write error instead of silently costing the row.

PROOF: no behaviour change on the happy path — tests/gateway/
test_delivery_ledger.py + test_delivery_ledger_single_connection.py
stay green (40 passed). Refactor is a deletion; the wrong claim is the
finding (S4-2c warning, low).
2026-09-22 15:55:09 +05:30
kshitijk4poor
4c41d9804c perf(gateway): the delivery ledger prunes inside the recording transaction
Salvaged from #117951 by @jonpol01 — same goal, reshaped to the cron
ledgers' in-transaction prune so no second connection, no
counter/interval state, no test clock.

`record_obligation` ran `_prune()` after its own transaction closed, and
`_prune()` opened a second connection + transaction, so every outbound
reply paid two SQLite connections. `_prune_unlocked(conn, now)` runs the
identical retention DELETEs (same age rule, same row cap) on the
recording connection inside the recording transaction, mirroring
cron/delivery_queue._prune_terminal_unlocked and
cron/executions._prune_unlocked. `_prune()` still exists for its
existing callers and delegates to it. A prune failure is still logged
at debug and never costs the recorded obligation.
2026-09-22 15:55:09 +05:30
kshitijk4poor
6351d60a6f docs(gateway): point the rich_sent_store restatements at _LOCK
The "atomic write != atomic load/merge/save triple" rationale was stated
three times in our text.  The canonical statement stays on
`rich_sent_store._LOCK`; the inline `_update` comment and the
`test_concurrent_record_async_calls_lose_no_entry` docstring now
reference it in one line each.  Behaviour-neutral: comments and a
docstring only.

PROOF: `git diff HEAD~1 --stat` touches 2 files, no code lines; ruff
clean; `import gateway.rich_sent_store` ok; scripts/run_tests.sh
tests/gateway/test_rich_sent_store_off_loop.py green.  The
sticker_cache.py async docstring and both off-loop test-module
docstrings are blame-attributed to contributor commits 4a77e841d4 /
1642d6a093 and were left untouched per the fold rule; the
helpers.py:116-127 two-lock comment is the canonical tracker statement
and was kept.
2026-09-22 15:54:29 +05:30
kshitijk4poor
9bc8fed25c fix(gateway): await mark_async in _branch_open_thread
The sixth ThreadParticipationTracker call site, `_branch_open_thread`
(an async def in slash_commands_session.py), still called the blocking
`threads.mark()` inline, paying `os.replace` on the event loop right
after `await adapter.create_handoff_thread(...)`.  Duck-typed via
`getattr(adapter, "_threads")`, so the change is the one-line await.

PROOF: `git grep -n '\.mark(' -- gateway plugins | grep -v mark_async`
now returns only the helpers.py:115 class docstring; the only test fake
reaching this path (tests/gateway/test_branch_thread_command.py
`_ThreadAdapter`) has no `_threads`, so no stub needed `mark_async`.
scripts/run_tests.sh tests/gateway/test_branch_thread_command.py
tests/gateway/test_thread_tracker_mark_off_loop.py green.
2026-09-22 15:54:29 +05:30
kshitijk4poor
4e87d3b04d refactor(gateway): give the tracker persist a real _to_thread seam
Commit 93fdc661b4 patched ``helpers.asyncio.to_thread``, but
``helpers.asyncio is asyncio`` so that was a global patch, not a module
seam.  ``helpers._to_thread = asyncio.to_thread`` is now the attribute
``mark_async`` awaits, and the read-after-mark test patches that name and
leaves ``asyncio.to_thread`` untouched.

PROOF: with ``mark_async`` mutated back to ``asyncio.to_thread(self._save)``
the test fails ``mark_async never reached the to_thread handoff``; reverted,
``helpers._to_thread is asyncio.to_thread`` under PYTHONSAFEPATH=1 and
run_tests.sh over the 4 branch test files: 7 passed, 0 failed.
2026-09-22 15:54:29 +05:30
kshitijk4poor
a5bdde60ab docs(gateway): reflow the mark_async docstring to 80 columns
PROOF: whitespace-only reflow of the previous commit's paragraph; longest
line 78 columns, ruff clean.
2026-09-22 15:54:29 +05:30