Files
hermes-agent/tui_gateway/session_workdir.py
teknium1 d9e10e0eb1 fix(tui): a secondary profile's key save and session model stay in that profile
One Desktop backend serves several profiles. Two session-bound paths read or
wrote the LAUNCH profile instead of the session's:

- model.save_key was not @_profile_scoped: a key saved from a secondary
  session (session_id) or for an explicit profile landed in the launch
  profile's .env, and the handler then exported it into the shared
  os.environ. It now binds the profile scope like model.options, and the
  explicit os.environ publish is gone (save_env_value already publishes to
  the bound scope, and to os.environ only for the launch profile).
  reconcile_record() already skips a non-launch home, so the profile-param
  guard around it is dropped. model.disconnect had the same gap (it removed
  the launch profile's credentials) and gets the same decorator.
- session.create's info.model and the first state.db row resolved the
  default model from the launch profile's config. _session_default_model()
  resolves it under the session's own profile scope; the same launch-model
  fallback in the lazy resume info, the fallback session info, the live
  session identity and the branch row now use it too.

Found by the two-tenant Desktop backend canary
(tests/e2e/core/tenancy/test_two_tenant_desktop_backend.py): alpha's saved
key appeared in default's .env, and alpha's session.create reported
default's model.
2026-09-23 15:21:15 -07:00

549 lines
29 KiB
Python

"""Session working-directory + durable session row: cwd resolution/healing, session.db row ensure,
branch seed, history rewind, git meta persistence. Bodies are rebound onto server.py's globals at
install time (method_ctx.bind_module), so they reference server.py globals bare.
"""
from __future__ import annotations
import contextlib
from tui_gateway import git_probe
from .method_ctx import bind_module
def _normalize_completion_path(path_part: str) -> str:
expanded = os.path.expanduser(path_part)
if os.name != "nt":
normalized = expanded.replace("\\", "/")
if len(normalized) >= 3 and normalized[1] == ":" and normalized[2] == "/" and normalized[0].isalpha():
return f"/mnt/{normalized[0].lower()}/{normalized[3:]}"
return expanded
def _completion_cwd(params: dict | None = None) -> str:
params = params or {}
# A session bound to another profile resolves its workspace from THAT profile's config before the launch profile's
# env var; the dashboard's in-memory gateway does NOT inherit the PTY child's bridged TERMINAL_CWD, so a configured
# terminal.cwd is read directly.
raw = (params.get("cwd") or _sessions.get(params.get("session_id") or "", {}).get("cwd")
or _profile_configured_cwd(_profile_home(params.get("profile"))) or _launch_configured_cwd()
or os.environ.get("TERMINAL_CWD") or os.getcwd())
with contextlib.suppress(Exception):
resolved = os.path.abspath(os.path.expanduser(str(raw)))
if os.path.isdir(resolved):
return resolved
return os.getcwd()
def _workdir_terminal_cfg(key: str) -> str:
"""Stripped ``terminal.<key>`` from config, or "" when unset/unreadable."""
with contextlib.suppress(Exception):
terminal_cfg = _load_cfg().get("terminal", {})
if isinstance(terminal_cfg, dict):
return str(terminal_cfg.get(key) or "").strip()
return ""
def _terminal_task_cwd(session: dict | None) -> str:
"""The cwd terminal_tool should use for this TUI session (NOT host-validated: a non-local backend's cwd lives
inside the target environment)."""
return _terminal_task_cwd_with_source(session)[0]
def _terminal_task_cwd_with_source(session: dict | None) -> tuple[str, str]:
"""``(cwd, source)``: ``"session"`` for THIS session's workspace (``explicit_cwd``/tracked dir), ``"process"`` for
the global ``TERMINAL_CWD``/``terminal.cwd`` fallback — under per-session docker isolation that is a PREVIOUS
session's launch artifact, so terminal_tool refuses it as a bind-mount source."""
backend = _effective_terminal_backend()
if backend != "local":
# THIS session's explicit workspace beats the LAST session's env var.
if session and session.get("explicit_cwd") and session.get("cwd"):
return str(session["cwd"]), "session"
raw = os.environ.get("TERMINAL_CWD", "").strip() or _workdir_terminal_cfg("cwd")
if raw and raw not in {".", "auto", "cwd"}:
return raw, "process"
if backend == "ssh":
return "~", "process"
if session and session.get("cwd"):
return str(session["cwd"]), "session"
return _completion_cwd(), "process"
def _session_cwd(session: dict | None) -> str:
return str(session["cwd"]) if session and session.get("cwd") else _completion_cwd()
# Sources whose launch directory is an artifact of how the app was started, not a workspace the user picked.
_LAUNCH_CWD_NOT_A_WORKSPACE = {"desktop"}
def _context_cwd_is_launch_artifact(session: dict | None) -> bool:
"""Whether the session cwd came from app launch rather than user intent."""
return bool(session and not session.get("explicit_cwd") and _session_source(session) in _LAUNCH_CWD_NOT_A_WORKSPACE)
def _persisted_session_cwd(session: dict) -> str | None:
"""The cwd to stamp on the session's DB row, or None to leave it unset (launch-dir rule: ``_ensure_session_db_row``)."""
if session.get("explicit_cwd"):
return _session_cwd(session)
if _session_source(session) in _LAUNCH_CWD_NOT_A_WORKSPACE:
return None
return str(session.get("cwd") or "") or None # the session's OWN dir, never _session_cwd's gateway-wide fallback
def _heal_dead_cwd(cwd: str) -> str:
"""Resolve a session cwd inside a now-deleted directory (e.g. a removed linked worktree, which probes to no branch
while the sidebar folds it to the main lane): walk up to the first existing ancestor and take its common git root.
Local backends only — a remote/SSH cwd may legitimately not exist on the host, so callers skip healing there."""
raw = (cwd or "").strip()
if not raw or os.path.isdir(raw):
return raw
probe = raw
for _ in range(64):
parent = os.path.dirname(probe)
if not parent or parent == probe:
break
probe = parent
if os.path.isdir(probe):
break
if not os.path.isdir(probe):
return raw
with contextlib.suppress(Exception):
return git_probe.common_repo_root(probe) or git_probe.repo_root(probe) or probe
return probe
def _is_local_terminal_backend() -> bool:
backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower()
return not backend or backend == "local"
def _effective_terminal_backend() -> str:
"""Active terminal backend name (``local``, ``docker``, ``ssh``, ...): ``TERMINAL_ENV`` when set (launchers bridge
``terminal.backend`` into env), else the ``terminal.backend`` config key (in-process gateways skip that bridge)."""
backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower()
if not backend or backend == "local":
backend = _workdir_terminal_cfg("backend").lower()
return backend or "local"
def _display_session_cwd(session: dict | None) -> str:
"""Session cwd for display/probe surfaces, healed past deleted worktrees (healed value persisted back; local only)."""
cwd = _session_cwd(session)
if not _is_local_terminal_backend():
return cwd
healed = _heal_dead_cwd(cwd)
if healed and healed != cwd and session is not None:
session["cwd"] = healed
_persist_session_cwd_and_schedule_git_meta(session, healed)
return healed
def _reconcile_session_cwd_from_terminal(session: dict | None) -> bool:
"""Re-anchor a session that SETTLED in another worktree of the SAME repo. Returns moved. An agent told to work in
a fresh worktree `git worktree add`s and `cd`s in while the session stays pinned (labelled with the primary
checkout's branch). A plain `cd` is deliberately NOT a workspace move (see ``_apply_project_workspace``): a non-git
workspace stepping into a repo or a visit to an unrelated repo is browsing, and an explicitly chosen workspace is
never overridden. Local backends only (a remote cwd cannot be stat'ed or git-probed here)."""
# An explicit choice only moves by another explicit action; a cwd adopted HERE is marked `cwd_from_settle` so
# successive settles keep following.
if not session or not _is_local_terminal_backend():
return False
if session.get("explicit_cwd") and not session.get("cwd_from_settle"):
return False
try:
from tools.terminal_tool import get_session_cwd
if not (recorded := get_session_cwd(session.get("session_key") or "")):
return False
except Exception:
return False
resolved = os.path.abspath(os.path.expanduser(str(recorded)))
current = os.path.abspath(os.path.expanduser(_session_cwd(session)))
if resolved == current or not os.path.isdir(resolved):
return False
# Worktree ROOTS (folding to the common root would hide the move), both in a git tree, different from each other,
# sharing the SAME common .git dir.
landed, current_root = git_probe.repo_root(resolved), git_probe.repo_root(current)
if not landed or not current_root or landed == current_root:
return False
landed_common = git_probe.common_repo_root(resolved)
if not landed_common or landed_common != git_probe.common_repo_root(current):
return False
# This is the session's workspace now (a desktop launch-artifact cwd earns a real row); the settle marker keeps it
# overridable by the NEXT settle.
session.update(cwd=resolved, explicit_cwd=True, cwd_from_settle=True)
_register_session_cwd(session)
_persist_session_cwd_and_schedule_git_meta(session, resolved)
return True
def _emit_settled_session_info(sid: str, session: dict, agent) -> None:
"""Emit end-of-turn ``session.info``, reconciling a settled cwd first (the agent has stopped moving; riding the
turn-end event needs no new event type/round trip)."""
try:
_reconcile_session_cwd_from_terminal(session)
except Exception:
logger.debug("failed to reconcile settled session cwd", exc_info=True)
_emit("session.info", sid, _session_info(agent, session))
def _session_source(session: dict | None) -> str:
source = str(session.get("source") or "").strip() if session else ""
return source or _resolve_session_platform()
def _register_session_cwd(session: dict | None) -> None:
if not session:
return
# Workspace moves must reach lazy/restarted runtimes, not just terminal tools.
# Do not reinitialize memory providers or invalidate the cached system prompt.
if hasattr(agent := session.get("agent"), "session_cwd"):
agent.session_cwd = session.get("cwd") or None
with contextlib.suppress(Exception):
from tools.terminal_tool import register_task_env_overrides
cwd, cwd_source = _terminal_task_cwd_with_source(session)
register_task_env_overrides(session["session_key"], {"cwd": cwd, "cwd_source": cwd_source})
def _workdir_row_model_config(session: dict) -> tuple[str, dict]:
"""``(model, model_config)`` for a fresh session row. The session's own model/effort/fast pick (composer override
or restored /model switch) must own the row: the agent isn't built yet at first prompt.submit, and writing the
global default here wins the INSERT-OR-IGNORE race (a reconnect silently reverts to the profile default).
model_config carries provider/reasoning/service_tier so resume restores effort + fast too."""
override = raw if isinstance(raw := session.get("model_override"), dict) else {}
row_model = str(override.get("model") or "").strip() or _session_default_model(session)
model_config: dict = {k: str(v) for k in ("model", "provider", "base_url", "api_mode") if (v := override.get(k))}
# A RESOLVED provider "custom" (named ``providers:``/``custom_providers:`` entry) persisted bare here is the origin
# of "No LLM provider configured" rows (resume routes to OpenRouter with no key). Recover the durable
# ``custom:<name>`` identity (matches _runtime_model_config).
if str(model_config.get("provider") or "").strip().lower() == "custom":
try:
from hermes_cli.runtime_provider import canonical_custom_identity
healed = canonical_custom_identity(
base_url=model_config.get("base_url") or None, model=model_config.get("model") or row_model or None)
if healed:
model_config["provider"] = healed
except Exception:
logger.debug("custom provider identity recovery failed (db row)", exc_info=True)
if (reasoning := session.get("create_reasoning_override")) is not None:
model_config["reasoning_config"] = reasoning
if (service_tier := session.get("create_service_tier_override")) is not None:
# "" is the in-memory sentinel for an explicit normal tier (bypasses _make_agent's profile fallback); persist a
# durable marker so resume can tell it from an inherited tier.
model_config["service_tier"] = service_tier or "normal"
# Same ``_branched_from`` marker the TUI /branch uses (list_sessions_rich + sidebar nesting).
if parent_session_id := session.get("parent_session_id"):
model_config["_branched_from"] = parent_session_id
# Room plumbing always follows the member profile. Canonical Bot Chats do too until the composer records an
# explicit chat-scoped pick plus the profile model it diverged from (see _stored_session_runtime_overrides).
for flag in ("room_plumbing", "follow_profile_config"):
if session.get(flag):
model_config[flag] = True
if isinstance(composer_profile := session.get("composer_override_profile"), dict):
model_config["composer_override_profile"] = composer_profile
return row_model, model_config
def _ensure_session_db_row(session: dict) -> bool:
"""Idempotently persist the session's DB row on first real activity (prompt.submit), so abandoned drafts never
leave an empty "Untitled" session. INSERT OR IGNORE: re-calls and the AIAgent's lazy create are no-ops. Returns
False only when the store is unavailable (no openable state.db) — prompt.submit fails the send loudly instead of
streaming into a store that will never save it; no key / best-effort / success are all True.
A cwd the user *chose* is always persisted. Otherwise the launch directory stands in only for terminal sessions
(the user deliberately ``cd``'d there; dropping it left the sidebar with no cwd AND no git_repo_root); desktop
launch dirs (``/``, home) stay null -> "No workspace".
See #98924.
"""
if not (key := session.get("session_key")):
return
# Persist into the session's own profile db (global remote mode), not the launch profile's — otherwise the unified
# list mis-tags the row and resume 404s ("session not found").
profile_home = session.get("profile_home")
with _workdir_owner_db(session, "failed to open profile db for session row") as db:
if db is _WORKDIR_DB_OPEN_FAILED:
return False
if db is None:
# Fail loud ONLY when the store failed to open (_db_error records the SessionDB open exception); None with
# no recorded error means "no store in this context" -> True.
# A None db with no recorded error means "no store in this context" (degraded harness, store
# deliberately absent) — that keeps the pinned best-effort contract and stays True. See #98924.
return _db_error is None
row_model, model_config = _workdir_row_model_config(session)
try:
db.create_session(
key, source=_session_source(session), model=row_model, model_config=model_config or None,
parent_session_id=session.get("parent_session_id") or None, cwd=_persisted_session_cwd(session),
# The login this session was opened under, in the same ``<provider>:<id>`` form the agent is
# built with — the row is the only place the identity reaches the store, and the upsert can't
# add it later (user_id is set at insert). None (no password provider, legacy token, stdio)
# leaves the column empty exactly as before.
user_id=_session_auth_user_id(session),
# Self-describing rows: aggregators merging several profile DBs can't rely on which file a row came
# from; a NULL is only repaired by the one-shot backfill.
# Stamp the launch profile explicitly instead of leaving NULL — NULL is exactly what the
# #94724 legacy-owner backfill exists to repair, and rows minted AFTER that one-shot
# backfill ran stayed NULL forever: profile-keyed matching then drops them from the sidebar
# and deep links can't resolve them (#99222).
profile_name=profile_name_for_home(profile_home) or _current_profile_name())
# Born hidden (session.create hidden=true, or set_hidden before the row existed): apply the deferred intent.
if session.get("pending_hidden"):
try:
if db.set_session_hidden(key, True):
session.pop("pending_hidden", None)
except Exception:
logger.debug("failed to apply pending hidden flag", exc_info=True)
except Exception as exc:
# Disk-full is not a soft failure: swallowed here, prompt.submit returns {"status":"streaming"} and the
# message vanishes silently.
_workdir_reraise_disk_full(exc, "failed to persist desktop session row")
return True
def _workdir_reraise_disk_full(exc: BaseException, log_msg: str) -> None:
"""Re-raise a disk-full write error (the caller must surface it); debug-log the rest."""
from hermes_state_errors import is_disk_full_error
if is_disk_full_error(exc):
raise exc
logger.debug(log_msg, exc_info=True)
# Seed row fields copied from the parent transcript. display_kind/metadata: timeline markers ride as role=user;
# dropping the tag re-plants them as bare user turns after a restart and corrupts the truncate ordinal address space.
_WORKDIR_SEED_FIELDS = (
"content", "reasoning", "reasoning_content", "reasoning_details", "codex_reasoning_items",
"codex_message_items", "display_kind", "display_metadata", "timestamp")
def _persist_branch_seed(session: dict) -> None:
"""Persist a seeded transcript once its row exists. Seeded messages (a branch's copied parent, a client's
opening turns) live only in ``session["history"]`` (ridden into the agent as ``conversation_history``, which
``_flush_messages_to_session_db`` skips by identity), so the row would otherwise resume without them. Runs
once: at create for a seeded session, else at the first submit after ``_ensure_session_db_row`` wrote the
row. ``seeded`` is stamped by session.create; a resumed session carries its stored transcript in
``history`` and must never re-append it."""
if not (key := session.get("session_key")) or not session.get("seeded") or session.get("_branch_seed_persisted"):
return
with session["history_lock"]:
seed = [dict(msg) for msg in (session.get("history") or [])]
if not seed:
return
with _session_db(session) as db:
if db is None:
return
try:
# Chunked so each BEGIN IMMEDIATE stays short (a seed can be hundreds of rows); a mid-copy failure leaves a
# partial seed with _branch_seed_persisted unset.
# Bounded-chunk transactions (see #23254): a branch seed can be hundreds of rows; chunking keeps
# each BEGIN IMMEDIATE short so concurrent writers aren't starved.
db.append_messages_batch(
key, [{"role": msg.get("role", "user"), **{f: msg.get(f) for f in _WORKDIR_SEED_FIELDS}} for msg in seed],
chunk_rows=500)
session["_branch_seed_persisted"] = True
except Exception as exc:
_workdir_reraise_disk_full(exc, "branch seed persist failed")
def _persist_submit_user_row(session: dict, text: Any, display_kind: str | None) -> None:
"""Write the submitted user turn at send time, before the agent build and turn: the agent's own
crash persist only runs once the build finished, so quitting a frozen app during a slow first build
left a session row with no message (#111868). The dict is staged on the session already stamped
durable (the shape ``quiet_single_query`` re-stages an unanswered DM in) so the turn adopts it via
``_stage_turn_user_message`` and the flush writes no second row. A failed write stages nothing:
the turn's crash persist then writes the row as before."""
session.pop("_submit_user_row", None) # a failed/unsupported write must not acknowledge an older send
key = session.get("session_key")
if not key or not isinstance(text, str) or not text.strip():
return
from agent.context_compressor import _DB_PERSISTED_MARKER
from agent.message_metadata import stamp_message_timestamp
staged = stamp_message_timestamp({"role": "user", "content": text})
if display_kind:
staged["display_kind"] = display_kind
with _session_db(session) as db:
if db is None:
return
try:
staged["_row_id"] = db.append_message(
key, "user", content=text, display_kind=display_kind, timestamp=staged["timestamp"])
except Exception as exc:
_workdir_reraise_disk_full(exc, "submit-time user row persist failed")
return
staged[_DB_PERSISTED_MARKER] = True
session["_submit_user_row"] = staged
def _adopt_submit_user_row(session: dict, agent, persist_user_message: Any, text: Any) -> None:
"""Hand the row written at submit to the turn as its user dict (``agent._pending_cli_user_message``,
adopted by ``_stage_turn_user_message`` when the content matches). A prompt the prologue rewrote
(@-expansion, image parts) first updates that row so the durable transcript replays what the model
was sent and the ``api_content`` sidecar can address it; ``_row_id`` rides along for that stamp.
``text`` is THIS turn's raw submit: a staged row from an earlier send (its turn ended before the agent
ran) is discarded untouched, so the DB row stays the user's message and never a synthesized turn's text."""
staged = session.pop("_submit_user_row", None)
if not isinstance(staged, dict) or agent is None or staged.get("content") != text:
return
if staged["content"] != persist_user_message:
from agent.session_persistence import _durable_content
with _session_db(session) as db:
if db is None:
return
try:
db.set_user_message_content(
session["session_key"], staged["_row_id"], _durable_content(persist_user_message))
except Exception:
logger.debug("submit-time user row update failed; the turn writes its own row", exc_info=True)
return
staged["content"] = persist_user_message
from agent.session_persistence import _persist_lock
with _persist_lock(agent):
agent._pending_cli_user_message = staged
# Yielded by _workdir_owner_db when the profile db failed to OPEN (vs "no store in this context"); row creation fails loud.
_WORKDIR_DB_OPEN_FAILED = object()
@contextlib.contextmanager
def _workdir_owner_db(session: dict, fail_log: str):
"""Body of :func:`_session_db`; ``_ensure_session_db_row`` uses it directly so a patched ``_session_db`` can't alter rows."""
db, close_db = None, False
if profile_home := session.get("profile_home"):
try:
from hermes_state_registry import acquire
db, close_db = acquire(Path(profile_home) / "state.db"), True
except Exception:
logger.debug(fail_log, exc_info=True)
db = _WORKDIR_DB_OPEN_FAILED
else:
db = _get_db()
try:
yield db
finally:
if close_db and db is not None:
with contextlib.suppress(Exception):
from hermes_state_registry import release_or_close
release_or_close(db)
@contextlib.contextmanager
def _session_db(session: dict):
"""Yield the SessionDB that owns this session's row (profile-aware): a remote/profile session persists into its own
profile's ``state.db`` (fresh handle, closed on exit); else the shared ``_get_db()`` handle (left open). None if unavailable."""
with _workdir_owner_db(session, "failed to open profile db for session") as db:
yield None if db is _WORKDIR_DB_OPEN_FAILED else db
def _rewind_active_session_history(
session: dict, user_ordinal: int, *, require_retryable: bool = False) -> tuple[list[dict], dict, int]:
"""Rewind one canonical user turn while retaining carrier scaffolding. Caller holds ``history_lock``. Persistent
sessions go through ``SessionDB.rewind_user_turn`` (the durable transcript is the authority; memory is installed
only after the commit); a session without a key rewinds the warm history alone."""
from agent.context_compressor import history_before_user_originated_turn, retryable_user_text, user_originated_turn_view
history = _history_without_ephemeral_scaffolding(session.get("history", []))
user_indices = [i for i, m in enumerate(history) if user_originated_turn_view(m) is not None]
if user_ordinal < 0 or user_ordinal >= len(user_indices):
raise ValueError("target user message is no longer in session history")
session_key = str(session.get("session_key") or "").strip()
if session_key:
with _session_db(session) as db:
if db is None:
raise RuntimeError("session database is unavailable")
outcome = db.rewind_user_turn(
session_key, user_ordinal, warm_history=history, require_retryable=require_retryable,
adopt_row_ids=True)
installed, live_view, rewound_count = outcome.prefix, outcome.live_view, outcome.rewound_count
else:
target_index = user_indices[user_ordinal]
installed, live_view = history_before_user_originated_turn(history, target_index)
rewound_count = len(history) - target_index
if require_retryable:
retryable_user_text(live_view.get("content"))
installed = [message.copy() for message in installed]
session["history"] = installed
session["history_version"] = int(session.get("history_version", 0)) + 1
agent = session.get("agent")
if agent is not None:
agent._session_messages = installed
if hasattr(agent, "_last_flushed_db_idx"):
agent._last_flushed_db_idx = len(installed) if session_key else 0
if hasattr(agent, "_db_flush_scan_prefix"):
agent._db_flush_scan_prefix = installed[:] if session_key else None
return installed, live_view, rewound_count
def _history_without_ephemeral_scaffolding(history: list[dict]) -> list[dict]:
"""Return the durable transcript shape without transient recovery rows."""
from agent.session_persistence import _is_ephemeral_scaffolding
return [message.copy() for message in history if not _is_ephemeral_scaffolding(message)]
def _workdir_valid_generation(generation) -> bool:
"""A claimed DB probe generation: a positive int (bool excluded)."""
return not isinstance(generation, bool) and isinstance(generation, int) and generation >= 1
def _persist_session_git_meta(session: dict, cwd: str, generation: int) -> None:
"""Resolve + persist a session's git branch / repo root on a daemon thread: inline ``git`` probes on the
session-init / cwd-set path would stall startup on a slow or unreachable ``cwd``. Persists via the same
profile-aware db the caller wrote ``cwd`` to. Best-effort: a probe failure leaves the enrichment columns unset."""
session_key = session.get("session_key", "")
if not session_key or not cwd or not _workdir_valid_generation(generation):
return
# Snapshot routing fields; the live session dict may be gone when the thread runs.
db_session = {"session_key": session_key, "profile_home": session.get("profile_home")}
def _run() -> None:
try:
branch, root = git_probe.branch(cwd), git_probe.common_repo_root(cwd)
if not (branch or root):
return
with _session_db(db_session) as db:
if db is not None:
db.publish_session_git_metadata(session_key, cwd, generation, branch, root)
except Exception:
logger.debug("failed to persist session git metadata", exc_info=True)
threading.Thread(target=_run, name="git-meta", daemon=True).start()
def _persist_session_cwd_and_schedule_git_meta(session: dict, cwd: str, *, db=None) -> int | None:
"""Claim a DB-backed probe generation, then start Git enrichment."""
try:
with (contextlib.nullcontext(db) if db is not None else _session_db(session)) as owner_db:
if owner_db is None:
return None
generation = owner_db.update_session_cwd(session.get("session_key", ""), cwd)
except Exception:
logger.debug("failed to persist session cwd", exc_info=True)
return None
if not _workdir_valid_generation(generation):
return None
_persist_session_git_meta(session, cwd, generation)
return generation
def _set_session_cwd(session: dict, cwd: str) -> str:
from hermes_constants import translate_cwd_for_wsl_backend
cwd = translate_cwd_for_wsl_backend(str(cwd))
resolved = os.path.abspath(os.path.expanduser(cwd))
if not os.path.isdir(resolved):
raise ValueError(f"working directory does not exist: {cwd}")
# An explicit user choice: persisted as the workspace (not the launch-dir fallback), superseding a settle-adopted cwd.
session.update(cwd=resolved, explicit_cwd=True, cwd_from_settle=False)
_register_session_cwd(session)
# The synchronous DB write claims ordering authority; git probes may publish only for that exact generation.
_persist_session_cwd_and_schedule_git_meta(session, resolved)
with contextlib.suppress(Exception):
from tools.terminal_tool_lifecycle import cleanup_vm
cleanup_vm(session["session_key"])
return resolved
def register(server) -> None:
"""Publish this module's helpers onto ``server``, rebound to its globals."""
bind_module(globals(), server, skip=("_",))