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.
549 lines
29 KiB
Python
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=("_",))
|