fix(honcho): plug pinPeerName transition gaps

Three correctness gaps when honcho.json's identity-mapping config changes
mid-flight:

1. The gateway's agent cache signature ignored honcho identity keys, so
   editing peerName / pinPeerName / userPeerAliases / runtimePeerPrefix
   was silently dropped until an unrelated cache eviction. Extend
   _extract_cache_busting_config to fingerprint the resolved honcho
   config so the AIAgent rebuilds on the next message.

2. cmd_setup let single → multi flips orphan the pinned-pool history
   under peerName without warning. Detect the transition, warn that
   runtime users will resolve to fresh empty peers, and auto-steer to
   hybrid (alias the operator's runtime IDs back to peerName) so the
   operator's own continuity survives. yes / no overrides available.

3. README didn't document the orphaning behaviour. Add a "Migrating
   single → multi" callout under Deployment shapes.

Tests:
- TestPinTransition (test_pin_peer_name.py): fresh-manager flip resolves
  to runtime, in-process flip is gated by the per-key session cache
  (documents the gateway-cache-must-bust contract), 3 cache-bust
  signature tests for pin / aliases / prefix.
- TestProfilePeerUniqueness: two profiles pinned to distinct peerNames
  resolve to distinct peers; host-level peerName overrides root when
  pinned.
- test_single_to_multi_steers_to_hybrid_by_default and
  test_single_to_multi_yes_override_keeps_multi (test_cli.py): wizard
  guard end-to-end coverage.
This commit is contained in:
Erosika 2026-05-26 11:03:57 -04:00 committed by kshitij
parent 58987cb8b1
commit 6feb2afd50
5 changed files with 274 additions and 1 deletions

View file

@ -386,4 +386,50 @@ class TestSetupWizardDeploymentShape:
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is True
assert host["userPeerAliases"] == {"keep": "me"}
assert host["runtimePeerPrefix"] == "keep_"
assert host["runtimePeerPrefix"] == "keep_"
def test_single_to_multi_steers_to_hybrid_by_default(self, monkeypatch, tmp_path):
"""Flipping single → multi triggers a warning that auto-steers the
operator to ``hybrid`` (default), so their own runtime IDs keep
landing on peerName instead of orphaning the pinned-pool history.
"""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"pinPeerName": True, "peerName": "eri"}},
}
answers = [
"cloud", # deployment
"", # api key (keep)
"eri", # peer name
"hermetika", # ai peer
"hermes", # workspace
"multi", # deployment shape — triggers the guard
"hybrid", # guard response: accept the steer
"86701400", # telegram uid
"", # discord (skip)
"", # slack (skip)
"", # matrix (skip)
"", # runtime prefix (skip)
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is False
assert host["userPeerAliases"] == {"86701400": "eri"}
def test_single_to_multi_yes_override_keeps_multi(self, monkeypatch, tmp_path):
"""Operator can override the steer by answering ``yes`` and accept
the orphaning consequences. This is the explicit undo-the-pin path.
"""
initial_cfg = {
"apiKey": "***",
"hosts": {"hermes": {"pinPeerName": True, "peerName": "eri"}},
}
answers = [
"cloud", "", "eri", "hermetika", "hermes",
"multi", # deployment shape — triggers the guard
"yes", # guard response: confirm multi
"telegram_", # runtime peer prefix
]
host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg)
assert host["pinPeerName"] is False
assert host["userPeerAliases"] == {}
assert host["runtimePeerPrefix"] == "telegram_"

View file

@ -681,3 +681,189 @@ class TestPinUserPeerAlias:
}))
config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.pin_peer_name is True
class TestPinTransition:
"""Behavior when honcho.json flips ``pinPeerName`` true → false.
Covers two contracts:
1. A freshly-built manager picks up the flipped config and resolves
the same runtime ID to a new peer (no resolver staleness).
2. The gateway's agent-cache signature reflects honcho identity-mapping
changes, so a config edit busts the cached AIAgent on the next turn.
"""
def _pinned(self) -> HonchoClientConfig:
return HonchoClientConfig(
api_key="k",
peer_name="Igor",
pin_peer_name=True,
enabled=False,
write_frequency="turn",
)
def _unpinned(self) -> HonchoClientConfig:
return HonchoClientConfig(
api_key="k",
peer_name="Igor",
pin_peer_name=False,
enabled=False,
write_frequency="turn",
)
def test_fresh_manager_after_flip_resolves_to_runtime(self):
pinned_mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._pinned(),
runtime_user_peer_name="86701400",
)
_patch_manager_for_resolution_test(pinned_mgr)
before = pinned_mgr.get_or_create("telegram:86701400")
assert before.user_peer_id == "Igor"
unpinned_mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._unpinned(),
runtime_user_peer_name="86701400",
)
_patch_manager_for_resolution_test(unpinned_mgr)
after = unpinned_mgr.get_or_create("telegram:86701400")
assert after.user_peer_id == "86701400", (
"After flipping pinPeerName off, the same runtime ID must resolve "
"to its own peer — otherwise multi-user mode silently merges users."
)
def test_cached_session_survives_config_flip_in_same_manager(self):
mgr = HonchoSessionManager(
honcho=MagicMock(),
config=self._pinned(),
runtime_user_peer_name="86701400",
)
_patch_manager_for_resolution_test(mgr)
first = mgr.get_or_create("telegram:86701400")
assert first.user_peer_id == "Igor"
mgr._config = self._unpinned()
second = mgr.get_or_create("telegram:86701400")
assert second.user_peer_id == "Igor", (
"The per-key session cache is keyed by session-key, not by "
"resolved peer. In-process flips don't invalidate it — the "
"gateway cache must bust the whole manager instead."
)
def test_cache_busting_signature_reflects_pin_peer_name(self, tmp_path, monkeypatch):
"""Gateway agent cache must bust when honcho.json's pinPeerName flips."""
from gateway.run import GatewayRunner
cfg_path = tmp_path / "honcho.json"
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor", "pinPeerName": True}))
sig_pinned = GatewayRunner._extract_cache_busting_config({})
cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor", "pinPeerName": False}))
sig_unpinned = GatewayRunner._extract_cache_busting_config({})
assert sig_pinned["honcho.pin_peer_name"] != sig_unpinned["honcho.pin_peer_name"]
def test_cache_busting_signature_reflects_user_peer_aliases(self, tmp_path, monkeypatch):
from gateway.run import GatewayRunner
cfg_path = tmp_path / "honcho.json"
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor"}))
sig_no_aliases = GatewayRunner._extract_cache_busting_config({})
cfg_path.write_text(json.dumps({
"apiKey": "k",
"peerName": "Igor",
"userPeerAliases": {"86701400": "Igor"},
}))
sig_with_aliases = GatewayRunner._extract_cache_busting_config({})
assert sig_no_aliases["honcho.user_peer_aliases"] != sig_with_aliases["honcho.user_peer_aliases"]
def test_cache_busting_signature_reflects_runtime_peer_prefix(self, tmp_path, monkeypatch):
from gateway.run import GatewayRunner
cfg_path = tmp_path / "honcho.json"
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cfg_path.write_text(json.dumps({"apiKey": "k", "peerName": "Igor"}))
sig_no_prefix = GatewayRunner._extract_cache_busting_config({})
cfg_path.write_text(json.dumps({
"apiKey": "k",
"peerName": "Igor",
"runtimePeerPrefix": "telegram_",
}))
sig_with_prefix = GatewayRunner._extract_cache_busting_config({})
assert sig_no_prefix["honcho.runtime_peer_prefix"] != sig_with_prefix["honcho.runtime_peer_prefix"]
class TestProfilePeerUniqueness:
"""Each Hermes profile can pin to its own unique peerName.
Profile cloning copies host blocks, but operators routinely diverge them
afterwards (e.g. `hermes -p partner` pinned to a different person's peer).
The resolver must honor host-level ``peerName`` so two profiles in the
same workspace stay scoped to different Honcho peers.
"""
def _pinned_to(self, name: str) -> HonchoClientConfig:
return HonchoClientConfig(
api_key="k",
peer_name=name,
pin_peer_name=True,
enabled=False,
write_frequency="turn",
)
def test_two_profiles_pinned_to_different_peer_names_resolve_distinctly(self):
mgr_a = HonchoSessionManager(
honcho=MagicMock(),
config=self._pinned_to("alice"),
runtime_user_peer_name="86701400",
)
_patch_manager_for_resolution_test(mgr_a)
sess_a = mgr_a.get_or_create("telegram:86701400")
mgr_b = HonchoSessionManager(
honcho=MagicMock(),
config=self._pinned_to("bob"),
runtime_user_peer_name="86701400",
)
_patch_manager_for_resolution_test(mgr_b)
sess_b = mgr_b.get_or_create("telegram:86701400")
assert sess_a.user_peer_id == "alice"
assert sess_b.user_peer_id == "bob"
assert sess_a.user_peer_id != sess_b.user_peer_id, (
"Profiles pinned to distinct peer names must not collapse to "
"the same Honcho peer — otherwise profile isolation is fictional."
)
def test_host_peer_name_overrides_root_when_pinned(self, tmp_path, monkeypatch):
"""Host-level peerName wins so each profile can pin uniquely while
sharing a single root-level apiKey and workspace.
"""
config_file = tmp_path / "honcho.json"
config_file.write_text(json.dumps({
"apiKey": "k",
"peerName": "default-user",
"hosts": {
"hermes.partner": {
"peerName": "partner-user",
"pinPeerName": True,
},
},
}))
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "isolated"))
cfg = HonchoClientConfig.from_global_config(
host="hermes.partner", config_path=config_file,
)
assert cfg.peer_name == "partner-user"
assert cfg.pin_peer_name is True