diff --git a/cli.py b/cli.py index bc4f4a76befb..b85c8dd65161 100644 --- a/cli.py +++ b/cli.py @@ -1165,6 +1165,40 @@ def _reset_terminal_input_modes_on_exit() -> None: _active_worktree: Optional[Dict[str, str]] = None +def _build_concurrent_sessions_note(concurrent: list) -> str: + """Build a structured block for the agent's system prompt. + + When another Hermes session is active in the same git repository the agent + must know about it so it avoids destructive operations (reset --hard, + rebase, checkout --) that would clobber the other session's in-flight work. + + Returns an empty string when there are no concurrent sessions. + """ + if not concurrent: + return '' + import datetime as _dt + lines = [ + '[Concurrent sessions]', + 'Other active Hermes sessions are open in this repository:', + ] + for e in concurrent: + sid = (e.get('session_id') or '?')[:24] + pid = e.get('pid', '?') + branch = (e.get('metadata') or {}).get('branch', 'unknown') + started_raw = e.get('started_at') + try: + started = _dt.datetime.fromtimestamp(float(started_raw)).strftime('%H:%M:%S') + except Exception: + started = str(started_raw or '?') + lines.append(f'- ID: {sid}, branch: {branch}, PID: {pid}, started: {started}') + lines.append( + 'Uncommitted changes may exist in the shared working tree. ' + 'Avoid destructive git operations (reset --hard, checkout --, rebase) ' + 'unless you have confirmed no other session holds in-flight work.' + ) + return '\n'.join(lines) + + def _normalize_git_bash_path(p: Optional[str]) -> Optional[str]: """Translate a Git Bash-style path (``/c/Users/...``) to the native Windows form (``C:\\Users\\...``) that Python's ``subprocess.Popen`` @@ -1340,6 +1374,17 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]: except Exception as e: logger.debug("Error copying .worktreeinclude entries: %s", e) + # Lock the worktree so other processes (and `git worktree remove`) can see + # it is actively in use. Fail-soft: a lock failure never blocks the session. + try: + subprocess.run( + ["git", "worktree", "lock", "--reason", f"hermes pid={os.getpid()}", str(wt_path)], + capture_output=True, text=True, timeout=10, cwd=repo_root, + ) + logger.debug("Worktree locked: %s (pid=%s)", wt_path, os.getpid()) + except Exception as e: + logger.debug("git worktree lock failed (non-fatal): %s", e) + info = { "path": str(wt_path), "branch": branch_name, @@ -1405,6 +1450,16 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None: if not Path(wt_path).exists(): return + # Unlock before removal so `git worktree remove` succeeds even when a + # lock was placed at creation time. Fail-soft — never block cleanup. + try: + subprocess.run( + ["git", "worktree", "unlock", wt_path], + capture_output=True, text=True, timeout=10, cwd=repo_root, + ) + except Exception as e: + logger.debug("git worktree unlock failed (non-fatal): %s", e) + has_unpushed = _worktree_has_unpushed_commits(wt_path, timeout=10) if has_unpushed: @@ -3612,10 +3667,16 @@ def _claim_active_session(self, surface: str = "cli", *, stderr: bool = False) - try: from hermes_cli.active_sessions import try_acquire_active_session + # Store repo_root in the registry entry so that + # find_concurrent_repo_sessions() can match sessions by repository. + _repo_root = getattr(self, '_repo_root', None) or _git_repo_root() + _meta = {'repo_root': _repo_root} if _repo_root else None + lease, message = try_acquire_active_session( session_id=self.session_id, surface=surface, config=self.config, + metadata=_meta, ) except Exception as exc: logger.warning("Failed to claim active session slot: %s", exc) @@ -10959,6 +11020,27 @@ def run(self): pass self.show_banner() + # Warn when another Hermes session is already running in the same repo. + # Both sessions share the working tree and can clobber each other's + # uncommitted changes. We also inject a note into system_prompt so + # the agent itself acts defensively before the first user message. + try: + from hermes_cli.active_sessions import find_concurrent_repo_sessions + _repo_root = getattr(self, '_repo_root', None) or _git_repo_root() + if _repo_root: + _concurrent = find_concurrent_repo_sessions(_repo_root, self.session_id) + if _concurrent: + _ids = ', '.join(e.get('session_id', '?')[:20] for e in _concurrent) + self._console_print( + f'[bold yellow]⚠ Concurrent session(s) detected in this repo:[/] {_ids}\n' + f'[yellow] Both sessions share the same working tree. ' + f'Use [bold]hermes -w[/bold] (--worktree) to isolate each session on its own branch.[/]' + ) + _note = _build_concurrent_sessions_note(_concurrent) + if _note: + self.system_prompt = ((self.system_prompt or '') + '\n\n' + _note).strip() + except Exception: + pass # Surface any active supply-chain security advisories right after the # welcome banner. Quiet/single-query paths call this themselves. self._show_security_advisories() @@ -13756,6 +13838,21 @@ def _signal_handler_q(signum, frame): if query or image: if not cli._claim_active_session("cli", stderr=bool(quiet)): sys.exit(1) + # Concurrent-session check for single-query / piped mode. + # Uses stderr so the stdout output stays clean for downstream consumers. + try: + from hermes_cli.active_sessions import find_concurrent_repo_sessions + _sq_repo = getattr(cli, '_repo_root', None) or _git_repo_root() + if _sq_repo: + _sq_concurrent = find_concurrent_repo_sessions(_sq_repo, cli.session_id) + if _sq_concurrent: + _sq_ids = ', '.join(e.get('session_id', '?')[:20] for e in _sq_concurrent) + print(f'⚠ Concurrent Hermes session(s) in this repo: {_sq_ids}', file=sys.stderr) + _note = _build_concurrent_sessions_note(_sq_concurrent) + if _note: + cli.system_prompt = ((cli.system_prompt or '') + '\n\n' + _note).strip() + except Exception: + pass try: query, single_query_images = _collect_query_images(query, image) # Kanban workers spawn with ``hermes chat -q "work kanban task "``; diff --git a/hermes_cli/active_sessions.py b/hermes_cli/active_sessions.py index 7fdb9c2d7297..faae50ef6576 100644 --- a/hermes_cli/active_sessions.py +++ b/hermes_cli/active_sessions.py @@ -78,7 +78,18 @@ def active_session_limit_message(active_count: int, max_sessions: int) -> str: def _state_dir() -> Path: - return get_hermes_home() / "runtime" + # Allow an explicit override for CI and container setups. + override = os.environ.get("HERMES_SESSIONS_STATE", "").strip() + if override: + return Path(override) + # Anchor to the *installation root* rather than the active profile home so + # that Desktop, WebUI, and CLI sessions — which may each have a different + # $HERMES_HOME when profiles are in use — all write to the same registry. + try: + from hermes_constants import get_default_hermes_root + return get_default_hermes_root() / "runtime" + except Exception: + return get_hermes_home() / "runtime" def _state_path() -> Path: @@ -318,3 +329,32 @@ def active_session_registry_snapshot() -> list[dict[str, Any]]: entries = _prune_dead(_read_entries(state_path)) _write_entries(state_path, entries) return entries + + +def find_concurrent_repo_sessions( + repo_root: str, + our_session_id: str, +) -> list[dict[str, Any]]: + """Return live sessions sharing the same git repository, excluding ours. + + Used to warn the user (and the agent) that another Hermes session is + already active in the same working tree so they can avoid clobbering + each other's uncommitted changes. + + Returns an empty list when no concurrency is detected or the registry + is unavailable — callers must never fail if this function raises. + """ + if not repo_root: + return [] + try: + state_path = _state_path() + with _FileLock(_lock_path()): + entries = _prune_dead(_read_entries(state_path)) + _write_entries(state_path, entries) + return [ + entry for entry in entries + if entry.get("session_id") != our_session_id + and (entry.get("metadata") or {}).get("repo_root") == repo_root + ] + except Exception: + return [] diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index 3d130293377b..270ba797f14f 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -440,23 +440,34 @@ def _do_session_init(self, cfg, session_id: str, **kwargs) -> None: session = self._manager.get_or_create(self._session_key) # ----- B6: Memory file migration (one-time, for new sessions) ----- - # Skip under per-session strategy: every Hermes run creates a fresh - # Honcho session by design, so uploading MEMORY.md/USER.md/SOUL.md to - # each one would flood the backend with short-lived duplicates instead - # of performing a one-time migration. + # With per-directory strategy: migrate once per new session (safe, idempotent). + # With per-session strategy: every Hermes run is a fresh session, so migrating + # on every start would flood the Honcho backend with duplicates. Instead we + # migrate exactly once (first run after install) and mark it with a marker file. try: - if not session.messages and cfg.session_strategy != "per-session": + if session.messages: + pass # existing session — nothing to migrate + elif cfg.session_strategy != "per-session": from hermes_constants import get_hermes_home - mem_dir = str(get_hermes_home() / "memories") - self._manager.migrate_memory_files(self._session_key, mem_dir) - logger.debug("Honcho memory file migration attempted for new session: %s", self._session_key) - elif cfg.session_strategy == "per-session": - logger.debug( - "Honcho memory file migration skipped: per-session strategy creates a fresh session per run (%s)", - self._session_key, - ) + _hermes_home = get_hermes_home() + self._manager.migrate_memory_files(self._session_key, str(_hermes_home / "memories")) + logger.debug("Honcho memory migration attempted for new session: %s", self._session_key) + else: + from hermes_constants import get_hermes_home + _hermes_home = get_hermes_home() + _marker = _hermes_home / ".honcho-memory-migrated" + if _marker.exists(): + logger.debug("Honcho memory migration skipped: already done (%s)", self._session_key) + else: + migrated = self._manager.migrate_memory_files(self._session_key, str(_hermes_home / "memories")) + if migrated: + try: + _marker.touch() + except Exception: + pass + logger.debug("Honcho memory migration (one-time) for %s", self._session_key) except Exception as e: - logger.debug("Honcho memory file migration skipped: %s", e) + logger.debug("Honcho memory migration skipped: %s", e) # ----- B7: Pre-warming at init ----- # Context prewarm warms peer.context() (base layer), consumed via @@ -550,26 +561,35 @@ def _format_first_turn_context(self, ctx: dict) -> str: """Format the prefetch context dict into a readable system prompt block.""" parts = [] - # Session summary — session-scoped context, placed first for relevance + # Session summary is session-scoped: it only reflects the current run. + # We tag it with the session key so the agent can distinguish it from + # the peer-level sections that span multiple sessions (see note below). summary = ctx.get("summary", "") if summary: - parts.append(f"## Session Summary\n{summary}") + session_tag = f" [session: {self._session_key}]" if self._session_key else "" + parts.append(f"## Session Summary{session_tag}\n{summary}") + + # Peer-level sections (representation, card) are aggregated by Honcho + # across ALL sessions for this peer — the Honcho API does not expose a + # per-session filter for these fields. The note makes that explicit so + # the agent doesn't treat cross-session observations as current-run facts. + _peer_scope = "*(aggregated across all sessions — not limited to the current one)*" rep = ctx.get("representation", "") if rep: - parts.append(f"## User Representation\n{rep}") + parts.append(f"## User Representation {_peer_scope}\n{rep}") card = ctx.get("card", "") if card: - parts.append(f"## User Peer Card\n{card}") + parts.append(f"## User Peer Card {_peer_scope}\n{card}") ai_rep = ctx.get("ai_representation", "") if ai_rep: - parts.append(f"## AI Self-Representation\n{ai_rep}") + parts.append(f"## AI Self-Representation {_peer_scope}\n{ai_rep}") ai_card = ctx.get("ai_card", "") if ai_card: - parts.append(f"## AI Identity Card\n{ai_card}") + parts.append(f"## AI Identity Card {_peer_scope}\n{ai_card}") if not parts: return "" diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index df8c839aa817..da197c3145ab 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -368,8 +368,11 @@ class HonchoClientConfig: user_observe_others: bool = True ai_observe_me: bool = True ai_observe_others: bool = True - # Session resolution - session_strategy: str = "per-directory" + # Session resolution. + # "per-session" gives every Hermes run its own isolated Honcho session, + # which prevents memory cross-bleed when two sessions share a directory. + # Set "per-directory" explicitly to persist context across runs in a project. + session_strategy: str = "per-session" session_peer_prefix: bool = False sessions: dict[str, str] = field(default_factory=dict) # Raw global config for anything else consumers need @@ -493,7 +496,7 @@ def from_global_config( # sessionStrategy / sessionPeerPrefix: host first, root fallback session_strategy = ( host_block.get("sessionStrategy") - or raw.get("sessionStrategy", "per-directory") + or raw.get("sessionStrategy", "per-session") ) host_prefix = host_block.get("sessionPeerPrefix") session_peer_prefix = ( diff --git a/tests/honcho_plugin/test_client.py b/tests/honcho_plugin/test_client.py index 7e956aa54c30..b488ef8e0140 100644 --- a/tests/honcho_plugin/test_client.py +++ b/tests/honcho_plugin/test_client.py @@ -31,7 +31,7 @@ def test_default_values(self): assert config.timeout is None assert config.enabled is False assert config.save_messages is True - assert config.session_strategy == "per-directory" + assert config.session_strategy == "per-session" assert config.recall_mode == "hybrid" assert config.session_peer_prefix is False assert config.sessions == {} @@ -167,7 +167,7 @@ def test_session_strategy_default_from_global_config(self, tmp_path): config_file = tmp_path / "config.json" config_file.write_text(json.dumps({"apiKey": "***"})) config = HonchoClientConfig.from_global_config(config_path=config_file) - assert config.session_strategy == "per-directory" + assert config.session_strategy == "per-session" def test_context_tokens_default_is_none(self, tmp_path): """Default context_tokens should be None (uncapped) unless explicitly set."""