Merge pull request #72507 from NousResearch/bb/cli-resume-cwd-scoped

fix(cli): scope -c/--resume to the current workspace
This commit is contained in:
brooklyn! 2026-07-27 01:32:19 -05:00 committed by GitHub
commit 21dd2d4d41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 273 additions and 14 deletions

View file

@ -1304,13 +1304,49 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
return None
def _resolve_workspace_key() -> Optional[str]:
"""The current workspace identity for cwd-scoped resume.
Git repo root when CWD is inside a repo (so all sessions across its
subdirs/worktrees group together), else the CWD itself. Returns None when
neither can be determined callers fall back to the global MRU then.
"""
try:
import subprocess
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
return os.path.abspath(result.stdout.strip())
except Exception:
pass
try:
return os.getcwd()
except Exception:
return None
def _resolve_last_session(source: str = "cli") -> Optional[str]:
"""Look up the most recently-used session ID for a source."""
"""Look up the most recently-used session ID for a source.
Scoped to the current workspace first (git repo root, else cwd) so
``hermes -c`` from repo A continues repo A's last session rather than the
global MRU. Falls back to the unscoped MRU when no session matches the
current workspace, preserving the old behaviour for fresh directories.
"""
db = None
try:
from hermes_state import SessionDB
db = SessionDB()
ws_key = _resolve_workspace_key()
if ws_key:
sessions = db.search_sessions(source=source, limit=1, workspace_key=ws_key)
if sessions:
return sessions[0]["id"]
# Fallback: global MRU for this source.
sessions = db.search_sessions(source=source, limit=1)
return sessions[0]["id"] if sessions else None
except Exception:

View file

@ -135,6 +135,24 @@ def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]:
return "(s.cwd = ? OR s.cwd LIKE ? OR s.cwd LIKE ?)", [prefix, f"{prefix}/%", f"{prefix}\\%"]
def _workspace_key_clause(key: str) -> Tuple[str, List[str]]:
"""Match sessions whose ``workspace_key(row)`` equals ``key``.
Mirrors :func:`workspace_key`: a session belongs to workspace ``key``
when its recorded ``git_repo_root`` equals ``key``, or for rows that
predate per-session git metadata when its ``cwd`` is at or under
``key`` (so a session started in ``repo/src`` still groups with ``repo``).
Used by ``hermes -c``/``--resume`` to continue the most recent session in
the *current* workspace rather than the global MRU.
"""
prefix = key.rstrip("/\\") or key
cwd_clause, cwd_params = _cwd_prefix_clause(prefix)
return (
f"(s.git_repo_root = ? OR (COALESCE(s.git_repo_root, '') = '' AND {cwd_clause}))",
[prefix, *cwd_params],
)
# Session preview = the head of the first user message, shown wherever a
# session has no title (sidebar rows, pickers, exports, the desktop's
# `sessionTitle` fallback).
@ -8656,12 +8674,18 @@ class SessionDB:
source: str = None,
limit: int = 20,
offset: int = 0,
workspace_key: str = None,
) -> List[Dict[str, Any]]:
"""List sessions, optionally filtered by source.
Returns rows enriched with a computed ``last_active`` column (latest
message timestamp for the session, falling back to ``started_at``),
ordered by most-recently-used first.
Pass ``workspace_key`` to scope rows to one workspace matching
:func:`workspace_key` semantics (git repo root, else cwd). Used by
``hermes -c``/``--resume`` so the "last" session is the last one in
the *current* workspace, not the global MRU.
"""
select_with_last_active = (
"SELECT s.*, COALESCE(m.last_active, s.started_at) AS last_active "
@ -8671,20 +8695,24 @@ class SessionDB:
"FROM messages GROUP BY session_id"
") m ON m.session_id = s.id "
)
where_clauses = []
params: list = []
if source:
where_clauses.append("s.source = ?")
params.append(source)
if workspace_key:
ws_clause, ws_params = _workspace_key_clause(workspace_key)
where_clauses.append(ws_clause)
params.extend(ws_params)
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
params.extend([limit, offset])
with self._lock:
if source:
cursor = self._conn.execute(
f"{select_with_last_active}"
"WHERE s.source = ? "
"ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
(source, limit, offset),
)
else:
cursor = self._conn.execute(
f"{select_with_last_active}"
"ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
(limit, offset),
)
cursor = self._conn.execute(
f"{select_with_last_active}"
f"{where_sql} "
"ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
params,
)
return [dict(row) for row in cursor.fetchall()]
# =========================================================================

View file

@ -155,3 +155,198 @@ def test_resolve_last_session_not_limited_to_newest_started_20(tmp_path, monkeyp
monkeypatch.setattr("hermes_state.SessionDB", lambda: real_session_db(db_path=state_db))
assert _resolve_last_session("cli") == target
# ---------------------------------------------------------------------------
# cwd-scoped resume: -c prefers the last session in the current workspace.
# ---------------------------------------------------------------------------
class _WorkspaceAwareDB:
"""Fake SessionDB whose ``search_sessions`` honors ``workspace_key`` the
same way the real ``_workspace_key_clause`` does: a row matches the key
when its ``git_repo_root`` equals it, or (no repo root recorded) when its
``cwd`` is at or under it."""
def __init__(self, rows):
self._rows = rows
self.closed = False
def search_sessions(self, source=None, limit=20, workspace_key=None, **_kw):
rows = [r for r in self._rows if r.get("source") == source] if source else list(self._rows)
if workspace_key:
key = workspace_key.rstrip("/")
def _in_ws(r):
grr = (r.get("git_repo_root") or "").rstrip("/")
if grr:
return grr == key
cwd = (r.get("cwd") or "").rstrip("/")
return cwd == key or cwd.startswith(key + "/")
rows = [r for r in rows if _in_ws(r)]
rows.sort(
key=lambda r: float(r.get("last_active") or r.get("started_at") or 0),
reverse=True,
)
return rows[:limit]
def close(self):
self.closed = True
def test_resolve_last_session_prefers_workspace_session_over_global_mru(monkeypatch, tmp_path):
# Two sessions: a globally-recent one in repo B, an older one in repo A.
# CWD is repo A → -c must pick repo A's session, not the global MRU.
repo_a = tmp_path / "repo-a"
repo_b = tmp_path / "repo-b"
repo_a.mkdir()
repo_b.mkdir()
monkeypatch.chdir(repo_a)
# Pretend both are git repo roots so _resolve_workspace_key returns the dir.
monkeypatch.setattr(
"hermes_cli.main.subprocess.run",
lambda cmd, **kw: __import__("subprocess").CompletedProcess(
cmd, 0, stdout=str(repo_a), stderr=""
),
)
rows = [
{"id": "global_recent", "source": "cli", "started_at": 9000.0,
"last_active": 9000.0, "cwd": str(repo_b), "git_repo_root": str(repo_b)},
{"id": "repo_a_older", "source": "cli", "started_at": 1000.0,
"last_active": 1000.0, "cwd": str(repo_a), "git_repo_root": str(repo_a)},
]
monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows))
assert _resolve_last_session("cli") == "repo_a_older"
def test_resolve_last_session_falls_back_to_global_when_workspace_empty(monkeypatch, tmp_path):
# CWD has no matching session → fall back to the global MRU.
repo_a = tmp_path / "repo-a"
repo_a.mkdir()
monkeypatch.chdir(repo_a)
monkeypatch.setattr(
"hermes_cli.main.subprocess.run",
lambda cmd, **kw: __import__("subprocess").CompletedProcess(
cmd, 0, stdout=str(repo_a), stderr=""
),
)
rows = [
{"id": "elsewhere", "source": "cli", "started_at": 9000.0,
"last_active": 9000.0, "cwd": "/other/repo", "git_repo_root": "/other/repo"},
]
monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows))
assert _resolve_last_session("cli") == "elsewhere"
def test_resolve_last_session_workspace_matches_cwd_subdir_without_git_root(monkeypatch, tmp_path):
# No git repo root on the session (legacy row) — match via cwd prefix so a
# session started in repo/src still resumes from repo/.
repo = tmp_path / "repo"
repo.mkdir()
monkeypatch.chdir(repo)
monkeypatch.setattr(
"hermes_cli.main.subprocess.run",
lambda cmd, **kw: __import__("subprocess").CompletedProcess(
cmd, 1, stdout="", stderr="not a repo"
),
)
rows = [
{"id": "subdir_session", "source": "cli", "started_at": 500.0,
"last_active": 500.0, "cwd": str(repo / "src"), "git_repo_root": ""},
]
monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows))
assert _resolve_last_session("cli") == "subdir_session"
def test_resolve_last_session_workspace_key_filters_by_source(monkeypatch, tmp_path):
# A TUI session in repo A is newer than a CLI session in repo A; asking for
# source="cli" must skip the TUI row even within the same workspace.
repo = tmp_path / "repo"
repo.mkdir()
monkeypatch.chdir(repo)
monkeypatch.setattr(
"hermes_cli.main.subprocess.run",
lambda cmd, **kw: __import__("subprocess").CompletedProcess(
cmd, 0, stdout=str(repo), stderr=""
),
)
rows = [
{"id": "tui_recent", "source": "tui", "started_at": 9000.0,
"last_active": 9000.0, "cwd": str(repo), "git_repo_root": str(repo)},
{"id": "cli_older", "source": "cli", "started_at": 1000.0,
"last_active": 1000.0, "cwd": str(repo), "git_repo_root": str(repo)},
]
monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows))
assert _resolve_last_session("cli") == "cli_older"
def test_search_sessions_workspace_key_filters_at_sql_level(tmp_path, monkeypatch):
# End-to-end: search_sessions(workspace_key=...) must filter by
# git_repo_root, and fall back to cwd-prefix for rows without one.
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
import hermes_state
from pathlib import Path
db = hermes_state.SessionDB(db_path=Path(tmp_path / "state.db"))
try:
# repo A: two sessions — one with git_repo_root, one legacy (cwd only).
db.create_session("repo_a_rooted", source="cli", cwd=str(tmp_path / "repo-a"),
git_repo_root=str(tmp_path / "repo-a"))
db.create_session("repo_a_legacy", source="cli", cwd=str(tmp_path / "repo-a" / "src"))
# repo B: one session, globally most recent.
db.create_session("repo_b", source="cli", cwd=str(tmp_path / "repo-b"),
git_repo_root=str(tmp_path / "repo-b"))
with db._lock:
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (100.0, "repo_a_rooted"))
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (50.0, "repo_a_legacy"))
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (9000.0, "repo_b"))
db._conn.commit()
key = str(tmp_path / "repo-a")
rows = db.search_sessions(source="cli", limit=10, workspace_key=key)
ids = sorted(r["id"] for r in rows)
assert ids == ["repo_a_legacy", "repo_a_rooted"]
assert "repo_b" not in ids
# No workspace_key → global MRU includes repo_b.
all_rows = db.search_sessions(source="cli", limit=10)
assert all_rows[0]["id"] == "repo_b"
finally:
db.close()
def test_resolve_last_session_real_db_prefers_workspace(monkeypatch, tmp_path):
# End-to-end through the real SessionDB + _resolve_last_session: -c from
# repo A picks repo A's session even though repo B is globally newer.
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
import hermes_state
from pathlib import Path
repo_a = tmp_path / "repo-a"
repo_a.mkdir()
state_db = Path(tmp_path / "state.db")
real_db = hermes_state.SessionDB
db = real_db(db_path=state_db)
try:
db.create_session("repo_a", source="cli", cwd=str(repo_a), git_repo_root=str(repo_a))
db.create_session("repo_b", source="cli", cwd="/other/repo-b", git_repo_root="/other/repo-b")
with db._lock:
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (100.0, "repo_a"))
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (9000.0, "repo_b"))
db._conn.commit()
finally:
db.close()
monkeypatch.chdir(repo_a)
monkeypatch.setattr(
"hermes_cli.main.subprocess.run",
lambda cmd, **kw: __import__("subprocess").CompletedProcess(
cmd, 0, stdout=str(repo_a), stderr=""
),
)
monkeypatch.setattr("hermes_state.SessionDB", lambda: real_db(db_path=state_db))
assert _resolve_last_session("cli") == "repo_a"