Files
hermes-agent/gateway/session_policy_refresh.py
yoniebans 9bbe8e880f fix(gateway): a resume expands ${VAR} from the profile's current .env
The resume refresh read config.yaml with the gateway process environment, so a ${VAR} that the profile's .env sets differently produced a changed value and a needless new generation on every attach. It now reads config.yaml the way session creation does.
2026-11-05 12:09:34 +01:00

297 lines
18 KiB
Python

"""A resumed local session re-reads its config-derived settings (CFG-2); its MCP servers change the same way (CFG-3).
Main's ``hermes -c`` was a new process: toolsets, MCP servers, ``terminal.backend``, ``max_turns``, checkpoints
and the reasoning default came from the config it started with, while the stored session kept its model/provider.
The owner keeps that line on ``session.resume``: it re-derives exactly those fields from the session profile's
current config and commits them as a new policy generation. ``reload.mcp`` commits the MCP part alone for the
calling session. The creation flags (request_json), the model route and a committed ``/reasoning`` or workspace
pick stay; a session with a turn running or queued keeps the policy that turn was admitted under. A Bot Chat an
earlier build committed frozen follows its profile from the open on (``follow_bot_chat``).
"""
import asyncio
import json
import logging
from dataclasses import asdict, replace
from functools import partial
from pathlib import Path
logger = logging.getLogger(__name__)
def _follow(target, live, key):
if key in live:
target[key] = live[key]
else:
target.pop(key, None)
def refreshed_policy(policy, live, terminal, *, picked=(), mcp_only=False):
"""``(policy, secrets, replaced)``: *policy* with its config-derived fields from *live* (the profile's
config) and *terminal* (its terminal scope), or only its MCP servers (*mcp_only*); explicit picks (launch
flags, the *picked* policy edits) stay. *secrets* are the refreshed sections' private values, *replaced*
the sections (``None``: terminal) whose stored private values they supersede. *policy* itself when
nothing moved; a rotated private value alone (its redacted tree unchanged) moves it."""
from gateway.session_policy import _extract_config_secrets, present_sections
from gateway.session_policy_credentials import secrets_moved
old, config = json.loads(policy.config_json), json.loads(policy.config_json)
live, secrets, replaced = present_sections(live), {}, set()
if mcp_only:
toolsets, terminal = policy.toolsets, json.loads(policy.terminal_json)
else:
toolsets = _settings(policy, config, live, picked)
terminal = _terminal(policy, terminal, picked)
_extract_config_secrets(terminal, secrets, (None,))
replaced.add(None)
toolsets = _mcp_servers(policy, config, live, toolsets, secrets, replaced)
if (config == old and toolsets == policy.toolsets and terminal == json.loads(policy.terminal_json)
and not secrets_moved(policy.config_secret_ref, secrets, replaced)):
return policy, {}, set()
policy = replace(policy, toolsets=toolsets, config_json=json.dumps(config), terminal_json=json.dumps(terminal))
return policy, secrets, replaced
def _settings(policy, config, live, picked):
"""Follow the config-derived settings into *config*; the toolsets they select."""
from gateway.session_policy import _resolve_toolsets, session_pinned
request = json.loads(policy.request_json)
live_agent = live.get('agent') or {}
agent = dict(config.get('agent') or {})
keys = [] if 'max_turns' in request else ['max_turns']
# The session's own record (a pick, or a reset back to the profile) wins over the receipt history.
chosen = session_pinned(config, 'reasoning', 'reasoning' in request or 'reasoning' in picked)
keys += [] if chosen else ['reasoning_effort', 'reasoning_overrides']
toolsets = policy.toolsets
if 'toolsets' not in request and policy.editor_mcp_json is None:
keys.append('disabled_toolsets')
_follow(config, live, 'platform_toolsets')
toolsets = tuple(sorted(_resolve_toolsets({}, live, policy.source, policy.safe_mode, policy.coding_posture)))
for key in keys:
_follow(agent, live_agent, key)
if agent or 'agent' in config:
config['agent'] = agent
if 'max_turns' not in request:
_follow(config, live, 'max_turns')
_follow(config, live, 'checkpoints')
if request.get('checkpoints') is True:
section = config.get('checkpoints')
section = dict(section) if isinstance(section, dict) else {'enabled': bool(section)} if section else {}
config['checkpoints'] = {**section, 'enabled': True}
return toolsets
def _terminal(policy, terminal, picked):
from gateway.session_policy import terminal_uses_host_cwd
terminal, request = dict(terminal), json.loads(policy.request_json)
# The session's own folder, where it now is, under build_policy's rule; a committed Move to project is
# the user's pick on every backend (it rewrote the policy, never the launch request).
if request.get('cwd_explicit') is True or 'workspace' in picked or terminal_uses_host_cwd(terminal):
terminal['TERMINAL_CWD'] = policy.cwd
return terminal
def _mcp_servers(policy, config, live, toolsets, secrets, replaced):
"""The profile's MCP servers as this session's own selection takes them now (its explicit pick or platform
allowlist, ``no_mcp`` and disabled entries respected): their definitions into *config*, private values split
off as build_policy does (a managed worker binds this config, so it connects exactly these), and their
names into the returned toolsets. An editor-MCP session keeps its frozen servers."""
from gateway.session_policy import _extract_config_secrets, _resolve_toolsets
from hermes_cli.tools_config import enabled_mcp_server_names
from hermes_state_runtime import RuntimeStoreError
if policy.editor_mcp_json is not None:
return toolsets
request = json.loads(policy.request_json)
sections = [section if isinstance(section, dict) else {} for section in (config.get('mcp_servers'), live.get('mcp_servers'))]
pick, picked, enabled = request.get('toolsets'), set(), enabled_mcp_server_names(live)
if pick is not None:
# The launch pick's servers are an allowlist: each stays only while config enables it, and once all
# are gone the chat has none, never every enabled server. A server that is now disabled or removed
# leaves the pick before it is re-validated (it no longer validates, and keeping the frozen
# definitions would start the server the user turned off).
known = set(sections[0]) | set(sections[1])
server = partial(_server_name, known)
picked = {server(name) for name in pick if isinstance(name, str) and server(name) in known}
pick = [name for name in pick if not isinstance(name, str) or server(name) not in picked - enabled]
try:
selected = _resolve_toolsets({} if pick is None else {'toolsets': pick},
json.loads(json.dumps(live)), policy.source, policy.safe_mode, policy.coding_posture)
except RuntimeStoreError:
return toolsets # the rest of the launch pick no longer validates against the profile: keep the frozen servers
# A picked server config removed keeps a disabled stub in the stored section, so every later refresh
# (after an owner restart too) still knows that name as one of the pick's servers, not an unknown toolset.
stubs = {name: {'enabled': False} for name in picked - set(sections[1])}
if 'mcp_servers' in live or stubs:
config['mcp_servers'] = json.loads(json.dumps({**sections[1], **stubs} if stubs else live['mcp_servers']))
_extract_config_secrets(config['mcp_servers'], secrets, ('mcp_servers',))
else:
config.pop('mcp_servers', None)
replaced.add('mcp_servers')
servers = selected & enabled & picked if picked else selected & enabled
return tuple(sorted((set(toolsets) - set(sections[0]) - set(sections[1])) | servers))
def _server_name(servers, name):
"""The server a pick name selects: ``mcp-<server>`` is its bare alias unless a server has that name."""
return name[4:] if name.startswith('mcp-') and name not in servers else name
def _prepare(authority, session_id, mcp_only=False, adopt_only=False):
"""Off the owner loop: the stored policy, its refresh from this profile home's files, and the generation
it was derived at. None when the session takes no refresh or nothing moved. *adopt_only*: the adoption
of a frozen Bot Chat alone, without the config-derived settings (a later open on the same attachment)."""
from gateway.session_local import load_profile_config
from gateway.session_policy import SURFACES, restore_policy
from hermes_state_local import local_receipt
from hermes_state_mutation_prepared import policy_picks
from tools.terminal_scope import build_profile_terminal_scope
generation = authority.db.get_session(session_id)['runtime_generation']
stored = local_receipt(authority.db, session_id)['policy']
old = restore_policy(stored)
# A bypass launch never reads the profile; cron, Kanban and room routes are not resumed CLI sessions.
if old.ignore_user_config or old.source not in SURFACES:
return None
home = Path(authority.db.db_path).resolve().parent
terminal = {} if mcp_only or adopt_only else build_profile_terminal_scope(home)
live, picked = load_profile_config(home), policy_picks(authority.db, session_id)
base = old
if not mcp_only:
# Opening a Bot Chat an earlier build froze adopts it: it follows its profile from now on.
from gateway.session_local_migration import follow_bot_chat
base = follow_bot_chat(authority, session_id, old, live, picked)
if adopt_only:
return None if base is old else (stored, old, base, {}, set(), generation)
policy, secrets, replaced = refreshed_policy(base, live, terminal, picked=picked, mcp_only=mcp_only)
return None if policy is old else (stored, old, policy, secrets, replaced, generation)
def _rebind(authority, session_id, old, policy, fresh, replaced):
"""Re-bind the private values like a policy mutation: policy identity covers config and terminal, and the
refreshed sections' private values are the profile's current ones (the stored ones there are neither
re-read nor checked: a rotated key is what the refresh picks up, not a revoked credential). A policy
that follows its profile from this open on keeps none of its followed sections' frozen values."""
from gateway.session_policy import FOLLOW_SECTIONS, bind_launch_key, launch_key
from gateway.session_policy_credentials import recover_config_secrets
skip = (*(FOLLOW_SECTIONS if policy.follows_profile else ()), *replaced)
secrets = recover_config_secrets(authority, old, skip) if old.config_secret_ref else {}
secrets.update(fresh)
return bind_launch_key(authority, session_id, policy, launch_key(authority, old), config_secrets=secrets)
def _pending_adoptions(authority):
"""Frozen Bot Chats an open found to adopt but could not commit (a turn running or queued, a concurrent
edit): the next open retries, whether or not that client is already attached."""
found = getattr(authority, '_pending_adoptions', None)
if found is None:
found = authority._pending_adoptions = set()
return found
def _in_flight(authority):
found = getattr(authority, '_policy_refreshes', None)
if found is None:
found = authority._policy_refreshes = {}
return found
async def refresh_settled(authority, session_id):
"""Wait out a refresh of *session_id* in flight. Its commit lands before its terminal cleanup (a sandbox
teardown can take 10-15 s) and its publication; an attach meanwhile would find the stored policy ahead
of the live one, which restore_local_session refuses."""
refreshes = _in_flight(authority)
while (running := refreshes.get(session_id)) is not None and not running.done():
logger.debug('Session %s waits for its policy refresh to publish', session_id)
await asyncio.wait({running}) # its outcome is its caller's; a cancelled waiter leaves it running
async def commit_refresh(authority, ref, *, mcp_only=False, adopt_only=False):
"""Commit an idle local session's refresh as its next policy generation: True when one was committed,
False when nothing moved. Raises the store's refusal (``session_busy`` while a turn runs or waits).
One refresh per session at a time, run to its publication even when the caller is cancelled: a second
waits for the first, then usually finds nothing moved."""
from gateway.session_runtime_workers import track_mutation
await refresh_settled(authority, ref.session_id)
refreshes = _in_flight(authority)
task = refreshes[ref.session_id] = track_mutation(authority, _commit_refresh(authority, ref, mcp_only, adopt_only))
task.add_done_callback(lambda done: refreshes.pop(ref.session_id, None) if refreshes.get(ref.session_id)
is done else None)
return await asyncio.shield(task)
async def _commit_refresh(authority, ref, mcp_only, adopt_only=False):
from gateway.session_runtime_workers import tracked_write
from hermes_state_mutation_prepared import commit_refreshed_policy
prepared = await asyncio.to_thread(_prepare, authority, ref.session_id, mcp_only, adopt_only)
# An MCP-only refresh never runs follow_bot_chat: it neither settles nor commits an adoption, so it
# leaves a pending one for the next open.
pending = _pending_adoptions(authority) if not mcp_only else set()
if prepared is None:
pending.discard(ref.session_id)
return False
stored, old, policy, secrets, replaced, generation = prepared
if policy.follows_profile and not old.follows_profile:
pending.add(ref.session_id) # until this commit lands
moved = json.loads(policy.terminal_json) != json.loads(old.terminal_json)
policy = _rebind(authority, ref.session_id, old, policy, secrets, replaced)
# Compare-and-swap against the receipt as stored (JSON lists), not its restored tuple form. The
# publication is the commit's own shielded follow-up: a caller cancelled mid-commit cannot leave the
# stored policy ahead of the live one (restore_local_session refuses that pair).
await tracked_write(authority, partial(commit_refreshed_policy, authority.db, epoch=authority.epoch,
session_id=ref.session_id, old=stored, new=asdict(policy),
generation=generation),
then=lambda _generation: _publish(authority, ref, moved))
pending.discard(ref.session_id)
return True
async def refresh_resumed_policy(authority, ref, *, attached=False):
"""Commit the config refresh of an idle local session before it is attached. Never fails the resume:
a busy session, a revoked launch credential or an unreadable profile keeps the stored policy. Only a turn
running or queued (or admitted during the commit) is expected; anything else is a WARNING.
*attached*: this client already holds the session (Desktop's warm reopen). The settings refresh is once
per attachment, but a frozen Bot Chat whose adoption a busy open refused is adopted on any later open."""
if attached and ref.session_id not in _pending_adoptions(authority):
return
try:
await (commit_refresh(authority, ref, adopt_only=True) if attached else commit_refresh(authority, ref))
except Exception as exc: # health: allow BLE001 -- a refresh is best-effort; the resume itself must proceed
reason = getattr(exc, 'reason', None) or type(exc).__name__
busy = reason in {'session_busy', 'unknown_execution', 'revision_conflict'}
logger.log(logging.INFO if busy else logging.WARNING, 'Session %s keeps its stored policy on resume: %s',
ref.session_id, reason)
def drop_session_terminal(authority, ref):
"""The session's own cached terminal environments (keyed by its route, or its ids under per-session
isolation): the terminal tool reuses one by key whatever backend built it. A profile-shared one stays.
Its recorded cwd (keyed by its route or task id) goes too: it wins over the new binding's directory."""
from gateway.session_authorities import owner_scope
from hermes_state_local import local_receipt
from tools.terminal_tool import _qualify_task_key, clear_session_cwd
from tools.terminal_tool_lifecycle import cleanup_vm
ids = {ref.session_id, local_receipt(authority.db, ref.session_id)['entry']['session_id']}
route = authority.sessions[ref.session_id].route
from gateway.session_policy import unbind_turn_workspace
with owner_scope(authority):
keys = {_qualify_task_key(key) for key in (f'session:{route}', *ids)}
for key in (route, *ids):
# The turn's workspace binding goes with them: it wins over the new binding's cwd too.
unbind_turn_workspace(key)
clear_session_cwd(key)
for key in keys:
cleanup_vm(key)
async def _publish(authority, ref, moved=False):
from gateway.session_local import local_session_info, publish_local_policy
if moved: # before the drain can run a turn on the new binding with the old environment
await asyncio.to_thread(drop_session_terminal, authority, ref)
publish_local_policy(authority, ref.session_id)
# A drain that ran during the off-loop commit saw the new stored policy beside the old live one and
# paused; with both equal again, anything admitted meanwhile runs.
authority._schedule(ref)
# Every viewer already attached adopts the new generation (its next fenced edit would otherwise be
# refused as stale) and repaints from the refreshed policy, as a reasoning/workspace edit publishes.
authority._publish_pending(ref)
authority.sessions[ref.session_id].event_stream.publish(
ref.session_id, local_session_info(authority, ref), event_type='session.info')