Skip to content

Multiplexed profiles no longer inherit another profile's terminal env in cron jobs and turns - #120307

Merged
teknium1 merged 4 commits into
mainfrom
fix/multiplex-terminal-env-scope
Sep 23, 2026
Merged

teknium1 merged 4 commits into
mainfrom
fix/multiplex-terminal-env-scope

Conversation

@teknium1

@teknium1 teknium1 commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

On a multiplexed host (one hermes gateway run or one hermes serve serving several profiles), a profile's cron jobs and turns now run in their own terminal environment and working directory. Before, they could pick up another profile's TERMINAL_CWD, .env values and shell state.

  • Cron and other work with no session (tools/terminal_tool.py): _resolve_container_task_id sent every task without a session key to the shared "default" environment. So profile B's cron tool calls reused the LocalEnvironment that the launch profile's job had created, including A's .env leftovers, its bridged TERMINAL_* values and its shell. When a routed (non-launch) home override is active, that work now gets its own key, home:<realpath>. The launch profile and single-profile processes still use "default", so nothing changes for them. With persistent Docker (profile-scoped containers) the routed key is profile:<name>, the same key that profile's session-bound work uses, so one profile never ends up with two long-lived containers (review finding from @andrexibiza).
  • Config bridge at gateway.run import (gateway/run.py): at import time the module does _hermes_home = get_hermes_home() and copies config.yaml values into os.environ. hermes serve imports this module lazily, from a session's agent build (tui_gateway.agent_callbacks) and under that session's profile override. Whichever secondary profile built first therefore wrote its terminal.cwd, agent.max_turns and the other bridged settings into the launch process env, and every later launch-profile turn and cron job inherited them. The bridge now reads get_process_hermes_home(). For a standalone gateway that returns the same home as before.

Root cause: both paths treated "no session" or "first import" as meaning the launch profile, but under multiplex that code runs in whichever profile's context reached it first.

Live repro (the two-tenant C7 canary: a real multiplexed gateway run or serve, 3 profiles, real cron ticks, real terminal subprocesses, loopback providers):

origin/main this branch
hermes gateway run canary red: provider[alpha] … carries default's env_marker, … carries default's workdir, provider[beta] … carries default's env_marker 1 passed
hermes serve canary (*) red 2/2: default: tool subprocess (cron job) carries beta's workdir: 'TERMINAL_CWD=…/work-beta-…', provider[default] … carries beta's custom_prompt 1 passed

(*) On origin/main the serve canary first stops at two other bugs the same lane found (session.create reports the launch model for a secondary profile; model.save_key writes the launch .env). Those fixes land in their own PRs. For this A/B they were applied temporarily and not committed, so the red above is caused only by the gateway.run change. The E2E files (tests/e2e/core/tenancy/) land separately in the E2E-suite PR and are not part of this PR.

Tests

  • New tests/gateway/test_config_env_bridge_authority.py::test_first_import_under_a_routed_override_bridges_the_process_home: imports gateway.run in a subprocess with a routed override set before the import, and asserts the launch home's max_turns and terminal.cwd reach the env. On base it is red with '222' == '111'; with the fix it is green.
  • Changed an existing assertion in tests/tools/test_terminal_scope_multiplex.py::test_routed_turn_reads_every_terminal_consumer_from_profile. It expected the old shared key (_resolve_container_task_id(None) == "default") for a routed profile, which is exactly the leak, so it now expects home:<realpath>. It is red on base (both params) and green with the fix.
  • New tests/tools/test_terminal_scope_multiplex.py::test_persistent_docker_routed_profile_keeps_one_container: two routed persistent-Docker profiles through the real _profile_runtime_scope boundary. For each, the key with no session must equal the key with a session (profile:<name>), and the two profiles' keys must differ. Red on 9ee3194 ('home:…/profiles/bee' == 'profile:bee'), green on this head.
  • scripts/run_tests.sh tests/gateway tests/tools: 18044 passed, 4 failed. All 4 files fail on unmodified origin/main too (A/B done by swapping in the two origin/main source files): test_compression_failure_session_sync, test_api_server_active_work_drain and test_session_hygiene_turnhold_adoption fail identically on both, and test_delegate_capacity_interrupt fails on both with different params each run under load. ruff check, check_no_tmp_literals.py and git diff --check are all clean.

Duplicate sweep: no open or closed PR fixes either root cause. These related PRs work on the same "cron under multiplex" area through a different mechanism. This PR neither supersedes nor conflicts with them:

Left open

  • The import-time bridge in gateway/run.py reads get_process_hermes_home(). Hosts that pin their process home (the WebUI mirror) should read get_routing_process_hermes_home(), but that helper landed on main in c7c9c18 after this branch's base. Switching needs a rebase, so it is left as a follow-up.
  • A routed default profile under a named launch profile with persistent Docker keys "default" for both session and cron work. That matches the existing session-keyed behavior and is not a change from this PR.

Infographic

Each profile keeps its own terminal

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 7cd737e — fix(terminal): persistent Docker keys a routed profile's cro

debug info

CI timings

CI timings · View report · View job

Wall time 6m17s vs 5m7s (+22.8%). 5 job(s) slower, 6 faster, 1 unchanged.

  • Python tests / Run tests: +103.0s
  • Profile artifact check / Reject profile archives: -32.0s
  • OS-specific tests / macOS-only tests: -25.0s
  • OS-specific tests / Windows-only tests: -7.0s
  • Python lints / Windows footguns (blocking): -6.0s

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery tool/terminal Terminal execution and process management area/profiles Multi-profile isolation, HERMES_HOME scoping sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Sep 23, 2026

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 9ee31940ed1c15f94cac805fd26ad74315e615c4 against PR base d2ef7db751afcbee20120d124c41fe871c54b7b1 and current main 6b6c7f4a99f1407eca3b6c06d691a972a8d80323.

The two root-cause fixes are pointed at the right ownership boundaries. gateway.run now anchors the import-time env bridge to the process home rather than whichever routed ContextVar happened to import the module first, and the routed-home cache key removes the concrete LocalEnvironment cross-profile reuse that the new regression demonstrates. That is complementary to #119324 (cron binds the owning home/secret/terminal scope), #109703 (foreign cron .env restoration), and #119129/#119355 (launch-home/routed-home classification); this PR is not a duplicate of those branches.

There is one remaining class-level blocker in the terminal key classifier:

P1 — the same routed profile can still split into two persistent Docker identities depending only on whether the call has a session id. _SessionScope.docker_profile_scoped explicitly promises one long-lived persistent Docker container per profile across CLI/gateway/WebUI. Session-bound profile B resolves to profile:B, but the new no-session branch returns home:<B-home> before the profile-scoped branch is reached. _get_terminal_env() uses that classifier result as the environment-cache key and Docker task_id, so routed cron/background work for B can create/reuse a different persistent Docker environment from B's ordinary turns. Cross-profile leakage is fixed, but same-profile persistent-container continuity is broken. I left the concrete path inline.

Required closure: canonicalize routed no-session persistent-Docker work onto the same profile identity used by session-bound work (or an equivalent canonical key policy), and add a regression that proves both sides of the invariant: same-profile session-bound + no-session calls reuse one Docker key/container, while different routed profiles remain isolated. The current regression exercises the local backend and therefore cannot detect this split.

Verification state is also not merge-ready at this head. Exact-head CI run 35874259676 is failure (the Python 3.11 test job is the failing lane); exact-head Nix flake check 35874259109 and Docker 35874259130 are green. main has also advanced since this PR's base, so the eventual implementation needs a fresh exact-head/current-main green receipt rather than inheriting the branch's existing partial evidence.

I did not find another demonstrated defect worth inventing. Once the Docker identity is canonicalized and the exact current head is green against current main, the terminal side of this multiplex ownership class looks clean.

Comment thread tools/terminal_tool.py Outdated
return f"shared:{shared}"
if not session_key:
return "default"
return _routed_home_task_key() or "default"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — keep routed no-session persistent-Docker calls on the same profile-scoped identity. For a session-bound routed profile, the classifier reaches the branch below and returns profile:<profile>; this new early return instead sends the same profile's cron/background call to home:<path>. _get_terminal_env() caches the DockerEnvironment under this key and passes it through as the Docker task_id, so profile B can now own two persistent containers solely because one call had a session id and the other did not. _SessionScope.docker_profile_scoped explicitly defines this mode as one long-lived container per profile across CLI/gateway/WebUI. Please canonicalize the no-session routed case to that same profile identity when persistent Docker is active, and add a regression proving same-profile session/no-session reuse while different routed profiles remain isolated.

@teknium1 teknium1 added the ci-reviewed applied to manually approve dangerous changes label Sep 23, 2026
@teknium1
teknium1 force-pushed the fix/multiplex-terminal-env-scope branch from 1ef360a to 83819be Compare September 23, 2026 23:31
…rminal environment

A multiplexed host runs every profile's cron jobs without a session key, and
_resolve_container_task_id collapsed all of them onto the shared "default"
environment. Profile B's cron tool calls therefore reused the LocalEnvironment
the launch profile's job created: B's terminal subprocess saw the launch
profile's .env residue and bridged TERMINAL_* (TERMINAL_CWD, backend policy).

Found by tests/e2e/core/tenancy/test_two_tenant_gateway.py (C7 canary): alpha's
cron env snapshot carried default's TENANT_MARKER and TERMINAL_CWD. Session-less
work under a routed home override now keys home:<realpath>; the launch profile
and single-profile processes keep "default". The unit test that pinned the
shared key for a routed profile is updated.
… on the process home

gateway.run bridges config.yaml into os.environ at import, keyed on
get_hermes_home(). The Desktop backend (hermes serve) first imports it lazily
from a session's agent build (tui_gateway.agent_callbacks._wire_callbacks),
under that session's routed profile override, so whichever secondary profile
built first latched its terminal.* (TERMINAL_CWD, backend...) and bridged
settings into the launch process env for every later launch-profile turn and
cron job.

Found by tests/e2e/core/tenancy/test_two_tenant_desktop_backend.py (C7 canary):
the default profile's cron env snapshot carried alpha's/beta's TERMINAL_CWD.
Use get_process_hermes_home(); identical for a standalone gateway.
…dges the launch home

Invariant for the import-time config bridge: a multiplexed backend's first import of
gateway.run can happen inside a routed profile's session (hermes serve imports it lazily
from an agent build), and the bridge must still write the launch home's agent.max_turns and
terminal.cwd into the process env. Red on the previous gateway.run (HERMES_MAX_ITERATIONS
came from the routed profile), green with get_process_hermes_home().
… its profile container

Under persistent Docker, profile B's session-bound work keys profile:B but its
session-less (cron) work keyed home:<path>, so the same profile ran two
long-lived containers. The routed no-session branch now returns the same
profile key as branch 3 when Docker is persistent (profile-scoped); other
backends keep the per-home key.
@teknium1
teknium1 force-pushed the fix/multiplex-terminal-env-scope branch from 83819be to 7cd737e Compare September 23, 2026 23:50
@teknium1
teknium1 merged commit bd14eb1 into main Sep 23, 2026
34 checks passed
@teknium1
teknium1 deleted the fix/multiplex-terminal-env-scope branch September 23, 2026 23:58
teknium1 added a commit that referenced this pull request Sep 24, 2026
…wn_failure patterns

#120307 and #120295 merged, so their cells are plain tests and the probe
machinery in core/_pending_fixes.py has no consumer left (known_failure stays).
torn-tree also excuses 'No module named' (a later release pair can die on a new
module instead of a moved name); the gemini listing gate now requires the 401
from the models endpoint, so an outage or 5xx fails the cell.
teknium1 added a commit that referenced this pull request Sep 24, 2026
…wn_failure patterns

#120307 and #120295 merged, so their cells are plain tests and the probe
machinery in core/_pending_fixes.py has no consumer left (known_failure stays).
torn-tree also excuses 'No module named' (a later release pair can die on a new
module instead of a moved name); the gemini listing gate now requires the 401
from the models endpoint, so an outage or 5xx fails the cell.
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/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 tool/terminal Terminal execution and process management type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants