Skip to content

Cron, kanban, ledger and adapter background paths follow the owning profile under multiplex (#119858, #119859, #119973, #119242; salvage #119974 #120045 #120019 #120009 #112888) - #120103

Merged
teknium1 merged 14 commits into
mainfrom
fix/s5-routed-cron
Sep 23, 2026
Merged

teknium1 merged 14 commits into
mainfrom
fix/s5-routed-cron

Conversation

@teknium1

@teknium1 teknium1 commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Under a multiplexed gateway, seven background execution points read state from the LAUNCH profile instead of the profile they act for; after this PR each follows the owning profile.

Closes #119858
Closes #119859
Closes #119973
Closes #119242
Salvages #119129 (@tancou, cherry-picked with authorship)
Supersedes #119867
Supersedes #112888
Supersedes #119974
Supersedes #120045
Supersedes #120019
Supersedes #120009

What changes

Root cause (one sentence)

os.environ, module globals and env-derived defaults hold the launch profile's values; every one of these paths read them where the served profile's ContextVar home override should have been the source.

Live A/B (two fake homes under scratch, set_multiplex_active(True), A→B→A)

Path base (a27b13056e7) head
cron own-profile bot-chat child (run_one_job, run_job patched, _run_bot_chat_turn captured) alpha/beta/alpha: HERMES_HOME=own profile, key=sk-<name>, no LAUNCH_ONLY_MARKER, no -p — already correct on main same
kanban comment author under _profile_cron_scope, HERMES_PROFILE unset worker / worker / worker alpha / beta / alpha
ledger rows + boot sweep (issue script) rows in profiles/alpha/state.db, profiles/beta/state.db; boot sweep claimed: [] rows in launch state.db; claimed [('ob-alpha','alpha'), ('ob-beta','beta')]
#119242 repro: mirror served into env, then override _is_routed_home(served)=False, _is_process_home(served)=True, env_loader home = served True / False / launch
control: multiplex inactive, env IS the profile _is_routed_home(served)=False, serves_routed_profile()=False same

Red on base → green on head

Test base head
test_bot_chat_own_profile_multiplex_home.py::test_an_own_profile_bot_chat_turn_runs_in_the_ticking_profiles_home FAIL at 786c0e3f9dc~1 (KeyError: 'ANTHROPIC_API_KEY'), PASS on a27b130 PASS
…::test_an_explicit_target_profile_still_wins_over_the_ticking_profile FAIL at 786c0e3f9dc~1, PASS on a27b130 PASS
test_kanban_persisted_identity.py::test_a_served_profiles_tick_authors_board_records_as_that_profile FAIL ('launch-host' == 'alpha'; also FAIL on #112888's head) PASS
…::test_a_dispatched_worker_keeps_its_pinned_identity_and_an_unnamed_caller_stays_generic PASS (control) PASS
test_delivery_ledger_process_home.py::test_boot_sweep_redelivers_a_reply_recorded_under_a_served_profile_scope[hermes-home-env|platform-default] FAIL PASS
test_planned_restart_notice_multiplex.py::test_a_served_profiles_reconnect_replays_the_owed_notice FAIL (awaited 0) PASS
test_restart_resume_pending.py::test_served_profile_reconnect_resumes_what_boot_deferred FAIL PASS
test_hermes_logging.py::test_routed_records_follow_their_own_profiles_redaction_policy[launch-opt-out|routed-env-opt-out] FAIL PASS
test_api_server_response_store_profile_scope.py::test_a_profile_key_never_reaches_another_profiles_responses[json|sse] FAIL PASS
test_secret_scope_pinned_launch_home.py::test_a_per_turn_env_mirror_cannot_relabel_the_launch_home_under_multiplex FAIL (served == launch) PASS
…::test_a_standalone_profile_process_keeps_following_its_env PASS (control) PASS

Existing tests: tests/tools/test_kanban_persisted_identity.py (added by #112888, never on main) was rewritten to two invariants; tests/test_hermes_logging.py parametrize trimmed 3→2 cases (routed-config-opt-out redundant with the binding proof). No assertion of a pre-existing main test was changed. tests/conftest.py resets the new _PINNED_PROCESS_HOME latch per test next to _MULTIPLEX_ACTIVE.

Not covered

  • Env-only HERMES_HOME switchers that still run in-process without an override: hermes_cli/gateway.py::_sync_hermes_home_from_systemd_unit (sudo --system adoption; single-profile CLI process) and hermes_cli/update_cmd_fleet.py restore around refresh_systemd_unit_if_needed (same). Both are safe because the pin only affects routing decisions, not get_hermes_home(); profiles._cleanup_gateway_service (reachable from a multiplexed dashboard) binds the override explicitly.
  • [Bug]: multiplexed cron deliver: bot-chat (own profile, no -p) runs the delivery turn against the gateway default HERMES_HOME #119858's issue text points at the pinned release v2026.8.31; the fix landed on main before this PR (3b0fe0c, 786c0e3). This PR adds the missing invariant tests; the issue closes as implemented-on-main with those SHAs.

Infographic

Owning profile followed

Co-authored-by: John Paul Soliva soliva.johnpaul@icloud.com
Co-authored-by: maximalang maximal04040404@gmail.com

Existing test changed (justification)

tests/agent/test_i18n.py::test_language_is_per_profile_under_multiplex switched profiles by rewriting os.environ["HERMES_HOME"] after set_multiplex_active(True). That is the standalone (T1) contract; a multiplexed turn binds set_hermes_home_override, and with the launch home pinned at activation (#119242) a later env mutation is ignored on purpose. Both assertions (fr then de) are unchanged — only the profile-switch mechanism now matches production (CI red on the first push was exactly this).

Review follow-up

Independent review of the first head (841fb2ee7d76) returned one blocker and two majors; all three fixed on head 3764ee576c8ebdefb969b2ccf1029a7f701f294a, red-on-head tests for each. Suites tests/hermes_cli tests/agent tests/gateway: 29405 passed, 4 failed — each red identically on a pristine origin/main worktree (checkout-identity external rows, a load TimeoutError, test_run_agent.py at the 300 s per-file cap under load 80; 267/267 alone).

# Finding Status Change Test (red on 841fb2ee)
1 Blocker — profiles._cleanup_gateway_service/rename_profile switch home env-only; with the launch home pinned into get_process_hermes_home(), DELETE /api/profiles/<x> from a multi-profile dashboard resolved get_service_name() to bare hermes-gateway and disabled/stopped/unlinked the HOST multiplexer's unit fixed Pin moved out of get_process_hermes_home() into get_routing_process_hermes_home() (#119129's design), and _cleanup_gateway_service also binds set_hermes_home_override(profile_dir) beside the env write (the gateway_migrate._home_env shape). rename_profile calls the same helper. tests/hermes_cli/test_profile_delete_service_name.py — pinned launch A, delete B: hermes-gateway-victim disabled+unlinked, hermes-gateway.service untouched
2 Major — set_multiplex_active(False) unpinned unconditionally, dropping an embedding host's explicit pin_process_hermes_home(launch) on transient toggles (_multiplex_read_mode, cron worker restore) fixed Activation pins only when no pin exists (_AUTO_PINNED_HOME); deactivation releases only that auto-pin test_secret_scope_pinned_launch_home.py::test_an_explicit_host_pin_survives_a_transient_multiplex_toggle
3 Major — no credit for @tancou's earlier #119129 (same mechanism, narrower split) fixed #119129 cherry-picked with authorship (fix(profiles): pin the launch home…, includes his 10-case parametrized test, docs, contributors/emails); this PR's own pin test rewritten on top. Body: Salvages #119129; comment posted on #119129 tests/agent/test_serves_routed_profile_pin.py (10 cases, from #119129)
4 Minor — env-only HERMES_HOME switchers not enumerated fixed listed under Not covered —
5 Minor — profiles.py growth on the facade not changed this fix adds 5 lines inside the existing _cleanup_gateway_service; no new behaviour appended to the facade —

get_hermes_home() now follows the env under multiplex again (the pin is routing-only), so the reviewer's minor about override-less readers ignoring env mutation no longer applies; test_multiplex_activation_pins_the_launch_home_but_env_readers_still_follow_the_env asserts it.

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 58d5ad0 — chore: retrigger CI (zero-job startup failure, merge_train a

debug info

CI timings

CI timings · View report · View job

Wall time 6m35s vs 5m41s (+15.8%). 7 job(s) slower, 4 faster, 2 unchanged.

  • Python lints / Windows footguns (blocking): +40.0s
  • OS-specific tests / Windows-only tests: +18.0s
  • OS-specific tests / macOS-only tests: +14.0s
  • Python tests / Run tests: +13.0s
  • Profile artifact check / Reject profile archives: -7.0s

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery area/profiles Multi-profile isolation, HERMES_HOME scoping sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Sep 23, 2026
@teknium1 teknium1 added the ci-reviewed applied to manually approve dangerous changes label Sep 23, 2026
jonpol01 and others added 11 commits September 23, 2026 07:51
…nch state.db

A multiplexed gateway connects each served profile's adapter inside
_profile_runtime_scope(<profile home>). The receive loop an adapter starts
while connecting inherits that home override, so every final reply the bot
sends is recorded from it. The ledger resolved its path through
get_hermes_home(), which follows the override, and the rows landed in
profiles/<name>/state.db. The boot sweep (sweep_recoverable) and the boot
flood-timer arming (pending_retries) run in the launch context and open the
launch state.db, so they never saw those rows. A served bot's reply cut off by
a crash or SIGKILL between finalize and platform ACK was never redelivered, a
flood-refused reply that spanned a restart was never retried, and
resume_pending was not cleared for a session whose answer sat in the ledger.

The ledger is meant to be one shared store: the boot sweep already scopes rows
by (platform, adapter_profile), and the profile purge terminalizes rows in the
shared store. _db_path now resolves from get_process_hermes_home(), as the
gateway's other process-level files do (gateway.status). It deliberately skips
the get_hermes_home() fallback that lifecycle_ledger uses when HERMES_HOME is
unset: a default gateway started in the foreground has no HERMES_HOME, and that
fallback would follow the override again.

Rows an earlier build already wrote to a profile's state.db stay where they are.
…ted_by when HERMES_PROFILE unpinned

Board records (comment author, task creator) were written as the generic
"worker" whenever the dispatcher did not pin HERMES_PROFILE, even though
the active Hermes profile was resolvable. Add _persisted_identity():
environment (HERMES_PROFILE_NAME / HERMES_PROFILE) first, else the active
profile derived from HERMES_HOME via hermes_cli.profiles, else "worker".
Wire it into _handle_comment author, _handle_create created_by, and the
own-comment skip filter in inject_new_comments_from_env so a worker's own
notes never re-enter its live turn as fake operator steering.

Identity is never taken from tool args: board records are injected into
future workers' prompts, so a caller-supplied author override could forge
an authoritative-looking directive (see #19713).

Tests: regression tests for env present, env absent with active profile,
and no profile; injection echo guard without env profile.
…he launch env

One resolver, hermes_cli.profiles.current_profile_name(): the HERMES_HOME override
(a multiplexed cron tick or routed gateway turn) names the profile first; the
dispatcher's HERMES_PROFILE pin is consulted only when no override is bound; the
process home last. #112888 added the HERMES_HOME-derived fallback but kept the env
pin FIRST, so under a multiplexer whose launch process carries a HERMES_PROFILE the
served profile's writes were still re-labelled with the host's name. The same
resolver replaces the per-module copies in hermes_cli/kanban.py, kanban_specify.py,
cron/lifecycle_guard.py and the kanban notify-target default.

Tests trimmed to two invariants (A->B->A under the override; control pin + generic
absence).

Closes #119859
Supersedes #112888
… the ticking home

Regression guard for #119858. The fix itself is already on main: 3b0fe0c pinned
the child HERMES_HOME to the override-aware source home and 786c0e3 moved the
spawn onto served_profile_child_env(target_home=home, inherit_credentials=True).
No existing test asserted the OWN-profile (no -p) leg A->B->A under multiplex; these
two do (red at 786c0e3~1, green on main).
… profiles

Every "does this task serve a ROUTED home" decision (serves_routed_profile,
_is_process_home, _is_routed_home, env_loader._process_hermes_home) compares the
home override with get_process_hermes_home(), which read os.environ["HERMES_HOME"]
live. A host that mirrors the served profile into that env var per turn (hermes-webui)
made every served profile look like the launch one: MCP registry scope None, bare
cross-profile connection names, launch residue kept in served child envs, the launch
GATEWAY_ALLOW_ALL_USERS grant seeded into the served scope.

set_multiplex_active(True) now pins the launch home (hermes_constants.
pin_process_hermes_home; first pin wins, an embedding host may pin explicitly) and
get_process_hermes_home() returns the frozen value while multiplex is active.
Standalone hermes -p x gateway run (multiplex inactive) keeps following the env.
No os.environ fallthrough is added anywhere.

Closes #119242
…otice and resumes deferred sessions

A multiplexed gateway defers two things for a platform that is offline at boot: the
planned-restart online notice (the marker keeps the target owed "for its reconnect", #112109)
and restart-interrupted sessions (left resume_pending "for the reconnect watcher"). The owed
set spans every served profile's home channel, and a served profile's session never falls
back to the default bot, so both wait on that profile's own reconnect.

Only the primary reconnect (_install_reconnected_adapter) acted on either.
_run_secondary_profile_reconnect published the adapter, redelivered failed obligations and
returned: the notice never went out and the marker outlived the outage, and the deferred
sessions stayed stranded until the freshness window aged them out.

The secondary reconnect now does what the primary does, through one shared helper for the
notice replay (still lock-serialized, delivered targets recorded, so a concurrent primary
replay cannot double-send).
…on policy

Under multiplex (and the Desktop/dashboard backend) every record is
formatted on the log QueueListener thread, after the profile scope that
produced it is gone. _ProfileRoutingFileHandler still writes a routed
profile's records to <profile>/logs/*.log, but RedactingFormatter ran
there with no home override, so _redact_enabled() returned the LAUNCH
profile's import-time snapshot and the vault scrub keyed on the launch
home. A launch profile with security.redact_secrets: false (or
HERMES_REDACT_SECRETS=false) therefore wrote every routed profile's
credentials raw into that profile's agent.log/errors.log/gateway.log,
and a routed profile's own opt-out was ignored.

The routing handler now binds the record's stamped home while a record
for another profile is formatted. Launch-profile records are untouched.
With no live secret scope (that thread has none), _redact_enabled reads
the profile's own .env for HERMES_REDACT_SECRETS, as its scope would;
without it the first call there cached a config-only answer for the
process.
The launch-opt-out case pins the routing handler's home binding; the .env case pins the
listener-thread .env read in _redact_enabled and also exercises the binding. The config.yaml
case duplicated the binding proof.

Salvaged from #120019
A multiplexed API server mirrors every route under /p/<profile>/ and
authenticates each mirror with that profile's key, but it kept one
ResponseStore at the home it was constructed in. Conversation names are
client-chosen strings ("main", "my-project"), so another profile's key
could post `conversation: <name>`, receive that profile's transcript,
instructions and session id as its agent's context, become the
conversation's tip (the owner's next turn replayed the intruder's
messages), and GET or DELETE the owner's responses by id.

The adapter now resolves the store from the request's profile home, as
the SessionDB cache already does: the construction home keeps
self._response_store (and its response_store.db), every other routed
home gets its own <home>/response_store.db, opened on first use and
closed on disconnect. The stream state captures its store when the
request starts, so a snapshot written after the scope ends (disconnect)
still lands in the right one.

Rows a secondary profile wrote into the shared store before this change
stay there, visible to the construction home's profile only; they are
not migrated.
…tated HERMES_HOME

`test_language_is_per_profile_under_multiplex` switched profiles by rewriting
`os.environ["HERMES_HOME"]` after `set_multiplex_active(True)`. That is the T1
standalone contract (environ IS the profile); a multiplexed turn binds
`set_hermes_home_override`, and since the launch home is pinned at activation
(#119242) a later env mutation is deliberately ignored. The two assertions are
unchanged; only the profile-switch mechanism now matches production.
tancou and others added 3 commits September 23, 2026 07:51
…lip routed-profile decisions

Symptom: a host that serves several profiles from one process and mirrors
the active turn's profile into `os.environ["HERMES_HOME"]` for legacy
readers (Hermes WebUI does this on every chat turn, next to the
context-local override) makes every launch-home decision see the served
profile as the launch profile. Two profiles that both configure `atlassian`
with different credentials share whichever MCP connection came first: a
READ_ONLY_MODE=false profile ends up calling a read-only server
(nesquena/hermes-webui#7721). The same misjudgement leaves the launch
residue in the served profile's child env, seeds the launch profile's
bridged allow-all grant into the served profile's secret scope, and lets
the served profile's `terminal.*` config bridge into the shared process env.

Cause: four launch-home checks compare the task's override with
`get_process_hermes_home()`, which reads `HERMES_HOME` live:
`agent.secret_scope.serves_routed_profile` (keys the MCP ledger via
`_mcp_registry_scope`, #108352 / #111481, and the check_fn cache, #111151),
`agent.secret_scope._is_process_home`, `tools.environments.local._is_routed_home`
and `hermes_cli.env_loader._process_hermes_home`. Under the mirror the two
sides are equal for every turn.

Change: `hermes_constants.pin_process_hermes_home(path | None)` lets the
host record the home it serves as its own; `get_routing_process_hermes_home()`
returns the pin when set, else `get_process_hermes_home()`; the four checks
compare against it. The pin is deliberately NOT folded into
`get_process_hermes_home()`: `get_hermes_home()` falls back to it for tasks
carrying no override (MCP loop, spawners), and the host's mirror exists
precisely so those readers see the served profile. Only "is this task
routed / is this the launch home" changes. Unpinned, behaviour is
byte-for-byte the old one; hosts that never mutate `HERMES_HOME` need not
call it. `activate_multi_profile_hosting()` is not the seam for this: it
flips `get_secret` fail-closed process-wide and freezes the launch env,
which an embedding host cannot adopt as a bug fix.

Tests (2 invariants, parametrized over the four checks plus the MCP ledger
key; red on main, green here): pinned + mirrored env -> the served home is
routed and the launch home is not, the MCP key is `(home_key, name)`,
`get_process_hermes_home()` still follows the env var; never pinned or
pinned-then-cleared -> old semantics, including "a mirrored env var IS the
launch home". `tests/conftest.py` resets the pin per test so the
module-global cannot leak between files.

Live repro (WebUI + a stdio FastMCP server named `atlassian` in two
profiles, one gated by READ_ONLY_MODE): base -> one ledger key
`'atlassian'`, the write profile lists only the read-only tools; fixed ->
`(<read_home_key>, 'atlassian')` and `(<write_home_key>, 'atlassian')`,
each profile lists its own tools.

Docs: `gateway/AGENTS.md` § Profile scope (one launch-home identity) and the
isolation table in `website/docs/user-guide/multi-profile-gateways.md`.
Also maps the author e-mail under contributors/emails/ (attribution check).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…it under multiplex

Builds on tancou's #119129 (cherry-picked above): the pin now lives in
get_routing_process_hermes_home() and only the four routed-profile DECISIONS read it.
get_process_hermes_home()/get_hermes_home() keep following HERMES_HOME, so an env-only
home switch in a multiplexed process resolves as before.

- set_multiplex_active(True) pins the launch home only when no host pin exists, and
  set_multiplex_active(False) releases only the pin it created itself. A transient toggle
  (gateway_migrate._multiplex_read_mode, cron external-worker restore) no longer drops an
  embedding host's explicit pin_process_hermes_home(launch).
- profiles._cleanup_gateway_service binds set_hermes_home_override(profile_dir) beside the
  env write. Under the previous head, DELETE /api/profiles/<x> from a multi-profile dashboard
  resolved get_service_name() against the pinned launch home -> bare `hermes-gateway`, and
  disabled/stopped/unlinked the HOST multiplexer's unit. Same path serves rename_profile.

Tests (red on the previous head): explicit pin survives True->False; env readers follow the
env while pinned; two-home delete removes hermes-gateway-victim and leaves hermes-gateway.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping ci-reviewed applied to manually approve dangerous changes comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/bug Something isn't working

Projects

None yet

5 participants