fix(gateway,cli): the session row's cwd is main's stamp, split from the execution cwd

Main stamps sessions.cwd only from the agent's own lazy create
(run_agent._launch_cwd_for_session: the process cwd, CLI-family label, local terminal
backend). /new and --create-if-missing rows stay empty, and -w records the repo root
because main never chdirs into the worktree (it sets TERMINAL_CWD). The gateway wrote
the policy cwd (the execution dir) on every row, so a piped bare `-c` (workspace-scoped
MRU) picked a newer /new or titled session that main skips as cwd-less.

Split row metadata from runtime cwd rather than changing execution:
- session.create takes an optional `row_cwd` (canonical contract + CREATE_FIELDS; absolute
  or "" = none). It is frozen in request_json; hermes_state_local._row_cwd writes it to
  the row at the creation commit, else the execution cwd as before (every other surface
  is unchanged). policy.cwd / TERMINAL_CWD are untouched: -w tools still run in the tree.
- GatewaySessionStore.create sends main's stamp: os.getcwd() for a CLI-family label on a
  local backend, "" for /new (CLIInitMixin._new_session_id) and --create-if-missing
  (create_titled_session, record_cwd=False) and for remote backends.
- TS/OpenRPC regenerated with gen_gateway_contracts.py.

Reset/compress/branch children keep copying policy['cwd'] (main's children inherit the
parent's row cwd via the compression/branch path; not exercised by the harness).

Red first: tests/gateway/test_session_row_cwd.py (row vs policy cwd, contract) and
test_gateway_session_store::test_row_cwd_is_mains_stamp... failed before the change.

(cherry picked from commit 532c257783f20ebe603a74b81e3551a7091feecf)
This commit is contained in:
Teknium committed 2026-10-12 22:50:09 -07:00
1 parent 650b96e892
commit e504e030e3
9 files changed
+136 -7

No files matched your search

@@ -4775,6 +4775,7 @@ export interface CanonicalCreateParams {
request_id?: string | null
source?: 'cli' | 'tui' | 'gui' | 'acp' | 'tool' | 'oneshot'
cwd?: string | null
row_cwd?: string | null
model?: string | null
provider?: string | null
base_url?: string | null
@@ -8587,6 +8587,18 @@
"default": null,
"title": "Cwd"
},
"row_cwd": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Row Cwd"
},
"model": {
"anyOf": [
{
+4 -1
View File
@@ -11,7 +11,7 @@ CREATE_FIELDS = frozenset({'request_id', 'source', 'cwd', 'model', 'toolsets',
'provider', 'base_url', 'reasoning', 'max_turns', 'ignore_rules', 'api_key', 'editor',
'yolo', 'safe_mode', 'ignore_user_config',
'skills', 'checkpoints', 'accept_hooks', 'pass_session_id', 'follow_profile_config',
'service_tier', 'cwd_explicit', 'run_budget', 'verbose'})
'service_tier', 'cwd_explicit', 'run_budget', 'verbose', 'row_cwd'})
# Sections a ``follow_profile_config`` session re-reads from the owning profile's CURRENT config each
# turn (main's Bot Chat row), credentials included: a frozen key never crosses a profile route change.
FOLLOW_SECTIONS = ('model', 'providers', 'custom_providers', 'fallback_providers', 'fallback_model', 'model_aliases',
@@ -263,6 +263,9 @@ def build_policy(params, config, *, private_secrets=None, profile_terminal=True)
cwd = params.get('cwd', str(resolve_agent_cwd()))
if not isinstance(cwd, str) or not Path(cwd).is_absolute() or not Path(cwd).is_dir():
raise RuntimeStoreError('invalid_params')
row_cwd = params.get('row_cwd')
if 'row_cwd' in params and (not isinstance(row_cwd, str) or (row_cwd and not Path(row_cwd).is_absolute())):
raise RuntimeStoreError('invalid_params')
if params.get('cwd_explicit') is False and 'cwd' in params:
cwd = _profile_workspace_cwd(get_hermes_home()) or cwd
from gateway.session_local_editor import validate_editor
+1 -1
View File
@@ -393,7 +393,7 @@ class CLIInitMixin:
return store.branch(branch_of, title)
sid = store.create({"toolsets": getattr(self, "enabled_toolsets", None),
"pass_session_id": getattr(self, "pass_session_id", False) or None,
"ignore_rules": getattr(self, "ignore_rules", False) or None})
"ignore_rules": getattr(self, "ignore_rules", False) or None}, record_cwd=False)
self.session_id = sid
return sid
+9 -4
View File
@@ -135,14 +135,19 @@ class GatewaySessionStore:
raise
return self.create(launch)
def create(self, launch=None, title=None):
def create(self, launch=None, title=None, *, record_cwd=True):
"""``session.create`` with the launch policy (and ``title``: the owner's resolve -> create ->
title step); adopts and returns the new session id."""
from agent.session_source import session_source_for
title step); adopts and returns the new session id. ``record_cwd=False`` (``/new``,
``--create-if-missing``) leaves the row's cwd empty, as main's explicit creates did."""
from agent.session_source import CLI_FAMILY_SOURCES, session_source_for
params = _creation_policy(launch or {})
params["cwd"] = os.path.realpath(os.getenv("TERMINAL_CWD") or os.getcwd())
# Main's row label: ``cli``, ``oneshot`` for a finite -q/-Q run, or an explicit --source.
params["source"] = session_source_for("cli")
# Main's row stamp (run_agent._launch_cwd_for_session): this process's cwd (``-w`` never
# chdirs, so the repo root), only for a CLI-family label on a local terminal backend.
local = (os.getenv("TERMINAL_ENV") or "local").strip().lower() in ("", "local")
params["row_cwd"] = os.getcwd() if record_cwd and local and params["source"] in CLI_FAMILY_SOURCES else ""
if title:
params["title"] = title
snapshot = self.call("session.create", request_id=uuid.uuid4().hex, **params)
@@ -398,4 +403,4 @@ def create_titled_session(title):
"""``chat -c NAME --create-if-missing`` (main's ``_create_titled_session``): the gateway creates the
titled session; the CLI resumes the id it returns. Launch flags are applied by the CLI's own
session open, as main's bare row took them at agent build."""
return session_store().create(title=title)
return session_store().create(title=title, record_cwd=False)
+13 -1
View File
@@ -49,7 +49,7 @@ def commit_local_session(db, *, epoch, receipt):
model,cwd,profile_name,origin_json,started_at) VALUES(?,?,?,?,?,'dm',?,?,?,?,?)
ON CONFLICT(id) DO NOTHING''',
(sid, policy['source'], receipt['principal_id'], route, entry['origin']['chat_id'],
policy['model'], policy['cwd'], db._own_profile_name(), _json(entry['origin']), started))
policy['model'], _row_cwd(policy), db._own_profile_name(), _json(entry['origin']), started))
conn.execute("INSERT INTO gateway_routing(scope,session_key,entry_json,updated_at) VALUES('',?,?,?)",
(route, _json(entry), started))
conn.execute('INSERT INTO state_meta(key,value) VALUES(?,?)', (key, encoded))
@@ -57,6 +57,18 @@ def commit_local_session(db, *, epoch, receipt):
return db._execute_write(write)
def _row_cwd(policy):
"""The sessions-row ``cwd``: the create request's ``row_cwd`` (``""`` = none) when it sent one,
else the execution cwd. Row metadata only (workspace grouping, ``-c``, cwd restore); the
frozen policy's ``cwd`` / ``TERMINAL_CWD`` stay where the session's tools run."""
try:
request = json.loads(policy.get('request_json') or '{}')
except (TypeError, ValueError):
request = {}
value = request.get('row_cwd') if isinstance(request, dict) else None
return policy['cwd'] if not isinstance(value, str) else (value or None)
def _policy_target(raw):
"""Current physical target a creation receipt routes to; None for a malformed receipt, which
then stays (its own reads already refuse it as ``storage_unavailable``) and cannot abort
+55
View File
@@ -0,0 +1,55 @@
"""Session-row ``cwd`` is row metadata, split from the execution cwd: the classic CLI records what
main's own lazy create stamped (``row_cwd``: the launch dir, or none for ``/new`` and
``--create-if-missing``) while the frozen policy keeps the directory its tools run in."""
from types import SimpleNamespace
import pytest
def _authority_fixture(tmp_path, monkeypatch):
from gateway import run
from gateway.config import GatewayConfig
from gateway.session import SessionStore
monkeypatch.setattr(run, '_load_gateway_config', lambda: {'platform_toolsets': {'cli': []}})
store = SessionStore(tmp_path / 'sessions', GatewayConfig())
runner = SimpleNamespace(session_store=store, _session_db=store._db, adapters={}, _draining=False,
_cached_agent_for=lambda route: None)
runner._adapter_for_source = lambda source: runner.adapters.get(source.platform)
return store, runner
@pytest.mark.asyncio
async def test_row_cwd_is_recorded_apart_from_the_execution_cwd(tmp_path, monkeypatch):
from gateway.session_authority import initialize_session_authority
from gateway.session_controls import AuthorityConnection
from gateway.session_local import create_local_session
from gateway.session_policy import restore_policy
from hermes_state_local import local_receipt
store, runner = _authority_fixture(tmp_path, monkeypatch)
authority = await initialize_session_authority(runner, profile_id='fixture', instance_id='owner')
actor = AuthorityConnection(authority, object(), {'user_id': 'human', 'profile_id': 'fixture'}).actor
work = tmp_path / 'repo' / '.worktrees' / 'wt'
work.mkdir(parents=True)
try:
rows = {}
for name, extra in (('default', {}), ('blank', {'row_cwd': ''}),
('repo', {'row_cwd': str(tmp_path / 'repo')})):
ref = create_local_session(authority, actor, {'request_id': name, 'source': 'cli', 'cwd': str(work),
'model': 'm', 'toolsets': [], **extra})
receipt = local_receipt(authority.db, ref.session_id)
policy = restore_policy(receipt['policy'])
assert policy.cwd == str(work) # the tools still run in the launch/worktree dir
rows[name] = authority.db.get_session(receipt['entry']['session_id'])['cwd']
assert rows == {'default': str(work), 'blank': None, 'repo': str(tmp_path / 'repo')}
with pytest.raises(Exception):
create_local_session(authority, actor, {'request_id': 'bad', 'source': 'cli', 'cwd': str(work),
'model': 'm', 'toolsets': [], 'row_cwd': 'relative/dir'})
finally:
store.close_all_db_handles()
def test_canonical_create_contract_accepts_row_cwd():
from tui_gateway.contracts.registry import CANONICAL_METHODS, canonical_param_problems
contract = CANONICAL_METHODS['session.create']
assert not canonical_param_problems(contract, {'source': 'cli', 'row_cwd': ''})
@@ -406,3 +406,41 @@ def test_new_session_before_the_gateway_session_opened_is_mains_placeholder(stor
host = types.SimpleNamespace(session_id="x", _session_db=db, _gateway_session_open=False)
assert CLIInitMixin._new_session_id(host, datetime(2026, 10, 12, 1, 2, 3)).startswith("20261012_010203_")
assert not [c for c in client.calls if c[0] == "session.create"]
def test_run_budget_flag_rides_session_create():
from hermes_cli.gateway_session_store import _creation_policy
assert _creation_policy({"run_budget": 90})["run_budget"] == 90.0
assert "run_budget" not in _creation_policy({"run_budget": None})
def test_verbose_flag_rides_session_create():
from hermes_cli.gateway_session_store import _creation_policy
assert _creation_policy({"verbose": True})["verbose"] is True
assert "verbose" not in _creation_policy({"verbose": None})
def test_row_cwd_is_mains_stamp_while_the_tools_run_in_terminal_cwd(store, monkeypatch, tmp_path):
"""Main's row cwd: the launching process's cwd on its first session (``-w`` never chdirs, so the
repo root), none on ``/new`` and ``--create-if-missing``. The execution cwd stays TERMINAL_CWD."""
from hermes_cli.cli_init_mixin import CLIInitMixin
from hermes_cli import gateway_session_store as gss
db, client = store
repo, tree = tmp_path / "repo", tmp_path / "repo" / ".worktrees" / "wt"
tree.mkdir(parents=True)
monkeypatch.chdir(repo)
monkeypatch.setenv("TERMINAL_CWD", str(tree))
monkeypatch.delenv("TERMINAL_ENV", raising=False)
monkeypatch.delenv("HERMES_SESSION_SOURCE", raising=False)
db.open_session(cli(), {"toolsets": ["file"]})
first = [p for m, p in client.calls if m == "session.create"][-1]
assert first["cwd"] == str(tree.resolve()) and first["row_cwd"] == str(repo.resolve())
host = types.SimpleNamespace(session_id="local-new", _session_db=db, _gateway_session_open=True)
CLIInitMixin._new_session_id(host, None)
assert [p for m, p in client.calls if m == "session.create"][-1]["row_cwd"] == ""
monkeypatch.setattr(gss, "session_store", lambda: db)
gss.create_titled_session("Fresh Named")
assert [p for m, p in client.calls if m == "session.create"][-1]["row_cwd"] == ""
monkeypatch.setenv("TERMINAL_ENV", "docker") # main stamps no host cwd for a remote backend
db.create({"toolsets": ["file"]})
assert [p for m, p in client.calls if m == "session.create"][-1]["row_cwd"] == ""
@@ -18,6 +18,9 @@ class CanonicalCreateParams(ProfileParams):
request_id: str | None = None
source: Literal['cli', 'tui', 'gui', 'acp', 'tool', 'oneshot'] = 'cli'
cwd: str | None = None
# The sessions-row ``cwd`` when it differs from the execution ``cwd`` (the classic CLI records
# main's stamp: the launch dir, ``""`` = none); ``cwd`` stays where the tools run.
row_cwd: str | None = None
model: str | None = None
provider: str | None = None
base_url: str | None = None