fix(gateway): register gateway.multiplex_profiles; explicit migrate --multiplex flips it with no standalone secondary

`hermes config set gateway.multiplex_profiles true` warned "not a recognized
config key" although gateway/config.py reads it: the key (and profile_routes)
were never in DEFAULT_CONFIG["gateway"]. Both are registered with their doc
comment; the CLI loaders deep-merge new keys, so no _config_version bump.

`hermes gateway migrate --multiplex` with two or more profiles but no
secondary running its own gateway printed "nothing to migrate" and left the
flag OFF. The explicit command now applies the one remaining step — flag on,
default gateway (re)started, the same rollback manifest (empty secondaries)
for --standalone. `hermes update`'s automatic hook keeps treating that case
as a no-op: it never flips modes on an install where nothing was running.
This commit is contained in:
teknium1
2026-09-12 18:10:40 -07:00
committed by Teknium
parent acbecf588a
commit 205645ee42
5 changed files with 89 additions and 12 deletions
+13
View File
@@ -1946,6 +1946,19 @@ DEFAULT_CONFIG = {
# (primary copy: state.db gateway_routing table). True for external tooling and downgrade
# safety; False stops producing the file.
"write_sessions_json": True,
# One gateway for every profile on this host: the DEFAULT profile's gateway also connects
# each named profile's bots (their own .env / config.yaml, per-profile secret scope) and
# stamps the profile into session keys. Flip with `hermes gateway migrate --multiplex`
# (records a rollback manifest; `--standalone` undoes it) or `hermes config set
# gateway.multiplex_profiles true` + `hermes gateway restart`. GATEWAY_MULTIPLEX_PROFILES
# in the environment overrides. Two profiles configuring the same bot token cannot be
# served together — the duplicate adapter is parked; `hermes profile create --clone`
# therefore leaves messaging channels behind unless --clone-channels is passed.
"multiplex_profiles": False,
# Route inbound chats of the default profile's bots to another profile
# (gateway/profile_routing.py): [{profile, platform, chat_id|user_id|guild_id|...}].
# Most-specific match wins; only read by the multiplexing default gateway.
"profile_routes": [],
# Scale-to-zero idle TIMEOUT only. When an instance is opted in via the NAS "Labs" toggle
# (HERMES_SCALE_TO_ZERO env stamp) AND messaging is relay-only/absent AND a wakeUrl is
# registered, the relay transport goes dormant so the platform (e.g. Fly autostop) can
+21 -11
View File
@@ -109,7 +109,9 @@ class MigrationPlan:
}
def eligible_for_migration(self) -> bool:
""">= 2 profiles, at least one secondary with its own gateway, multiplex off, no blockers."""
""">= 2 profiles, at least one secondary with its own gateway, multiplex off, no blockers.
This is the AUTO-migration (``hermes update``) bar; the explicit command also proceeds with
zero standalone secondaries (see :func:`cmd_migrate`)."""
return (
len(self.profiles) >= 2 and bool(self.standalone_secondaries)
and not self.already_multiplexed and not self.blocked
@@ -434,14 +436,22 @@ def format_plan(plan: MigrationPlan, *, dry_run: bool) -> list[str]:
for p in plan.standalone_secondaries:
what = " + ".join(x for x in (f"stop pid {p.pid}" if p.pid else "", f"uninstall {p.service_label()}" if p.service else "") if x)
steps.append(f" - {p.name}: {what}")
if len(plan.profiles) < 2: # the notice already says "only one profile exists"
return lines + _plan_tail(plan)
if not steps:
lines.append(" No secondary profile runs its own gateway; nothing to migrate.")
lines.append(" No secondary profile runs its own gateway; the only step is turning the flag on:")
else:
lines += [" Steps:", *steps, f" - default: set gateway.multiplex_profiles: true in {plan.default_home / 'config.yaml'}"]
target = plan.target_service_kind()
lines.append(f" - default: {'restart' if plan.default.has_gateway else 'start'} the gateway"
+ (f" via {target[0]}" if target else " (detached)") + f", verify it serves {len(plan.profiles)} profiles")
lines.append(f" - record removed services in {plan.default_home / MANIFEST_NAME} (rollback: hermes gateway migrate --standalone)")
lines += [" Steps:", *steps]
lines.append(f" - default: set gateway.multiplex_profiles: true in {plan.default_home / 'config.yaml'}")
target = plan.target_service_kind()
lines.append(f" - default: {'restart' if plan.default.has_gateway else 'start'} the gateway"
+ (f" via {target[0]}" if target else " (detached)") + f", verify it serves {len(plan.profiles)} profiles")
lines.append(f" - record the previous state in {plan.default_home / MANIFEST_NAME} (rollback: hermes gateway migrate --standalone)")
return lines + _plan_tail(plan)
def _plan_tail(plan: MigrationPlan) -> list[str]:
lines: list[str] = []
if plan.blockers:
lines += ["", " ✗ Blockers (fix these first, nothing will be changed):"]
lines += [f" • {b}" for b in plan.blockers]
@@ -635,10 +645,10 @@ def cmd_migrate(args) -> None:
return
if plan.already_multiplexed:
return
if plan.blocked:
sys.exit(1)
if not plan.standalone_secondaries:
return
if plan.blocked or len(plan.profiles) < 2:
sys.exit(1 if plan.blocked else 0)
# Zero standalone secondaries is still a migration when the user asks for it explicitly: the flag
# goes on and the default gateway restarts (the update hook keeps treating that case as a no-op).
if not getattr(args, "yes", False) and sys.stdin.isatty():
from hermes_cli.setup import prompt_yes_no
if not prompt_yes_no("Apply this migration now?", True):
+10
View File
@@ -1910,3 +1910,13 @@ class TestConfigCommandFailClosedSurface:
assert excinfo.value.code == 1
assert "not valid YAML" in capsys.readouterr().err
assert config_path.read_text(encoding="utf-8") == original
def test_gateway_multiplex_keys_are_recognized_config_keys():
"""``hermes config set gateway.multiplex_profiles true`` used to warn 'not a recognized config
key' although gateway/config.py reads it; the key (and profile_routes) live in DEFAULT_CONFIG."""
from hermes_cli.config import _validate_config_key
from hermes_cli.config_defaults import DEFAULT_CONFIG
assert DEFAULT_CONFIG["gateway"]["multiplex_profiles"] is False
assert _validate_config_key("gateway.multiplex_profiles") == (True, None)
assert _validate_config_key("gateway.profile_routes") == (True, None)
@@ -171,3 +171,31 @@ def test_update_hook_never_touches_single_profile_or_already_multiplexed(fleet,
fleet.services.clear(); fleet.pids.clear() # secondaries exist but run no gateway of their own
gm.maybe_auto_migrate_after_update()
assert capsys.readouterr().out == "" and _config_flag(fleet.root) is None
def test_explicit_migrate_with_no_standalone_secondaries_still_flips_flag_and_restarts_default(fleet, capsys, monkeypatch):
"""The user typed --multiplex: 'nothing to migrate' + flag left off was a no-op the user did not ask
for. The update hook keeps its no-op (previous test); the explicit command proceeds."""
fleet.services.clear(); fleet.pids.clear()
def _detached(home): # no service manager anywhere -> detached start writes the served record
fleet.services["default-detached"] = True
(fleet.root / "gateway.pid").write_text(json.dumps({"pid": os.getpid(), "hermes_home": str(fleet.root)}))
(fleet.root / "gateway_state.json").write_text(json.dumps({
"pid": os.getpid(), "hermes_home": str(fleet.root), "gateway_state": "running",
"served_profiles": ["default", "coder", "ops"]}))
return True
monkeypatch.setattr(gm, "_spawn_detached_gateway", _detached)
assert not gm.build_migration_plan().standalone_secondaries
with pytest.raises(SystemExit) as exc:
gm.cmd_migrate(SimpleNamespace(multiplex=True, standalone=False, dry_run=False, yes=True))
assert exc.value.code == 0
assert fleet.services.pop("default-detached") is True
assert _config_flag(fleet.root) is True
manifest = json.loads((fleet.root / gm.MANIFEST_NAME).read_text(encoding="utf-8"))
assert manifest["secondaries"] == [] and manifest["flag_was"] is False
assert ("default", "install") not in fleet.ops
out = capsys.readouterr().out
assert "serves 3 profiles" in out
# The same manifest rolls it back: flag restored, nothing to reinstall.
assert gm.rollback_migration(fleet.root) is True and _config_flag(fleet.root) is False
@@ -802,7 +802,23 @@ preflight:
fix and the one-liner to run later. Nothing is changed.
Single-profile installs are never migrated (there is nothing to gain), and an
install that is already multiplexing is left alone.
install that is already multiplexing is left alone. `hermes update` also does
nothing when no secondary profile runs its own gateway — it never flips modes
on an install where nothing was running.
The explicit command is different: `hermes gateway migrate --multiplex` with
two or more profiles and **no** standalone secondary gateway still applies the
one remaining step — it sets `gateway.multiplex_profiles: true`, (re)starts the
default gateway and writes the same rollback manifest (with an empty
`secondaries` list), so `--standalone` undoes it. You asked for multiplex; you
get multiplex.
:::tip Clones do not carry channels
`hermes profile create --clone` leaves the source's bot tokens and allowlists
behind (see [Profiles → messaging channels are never cloned](./profiles.md#messaging-channels-are-never-cloned---clone-channels-to-opt-in)),
so a fleet of clones no longer trips the duplicate-credential blocker below.
Older clones that still carry them are flagged by `hermes profile list`.
:::
### What the migration does