Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d530a0eaaa | |||
| 5dc56b9f3a | |||
| 066b7635ec | |||
| f8def2dfb6 |
@@ -30,10 +30,12 @@ import logging
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -59,6 +61,125 @@ _PASTE_COMMIT_TIMEOUT_S = 5.0
|
||||
_SETTLE_STABLE_POLLS = 3
|
||||
|
||||
|
||||
def mint_hermes_session_id() -> str:
|
||||
"""Generate a fresh Hermes session id (UUID4 string)."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
_SESSIONS_DDL = """\
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
cwd TEXT,
|
||||
started_at REAL NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
_MESSAGES_DDL = """\
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_calls TEXT,
|
||||
tool_name TEXT,
|
||||
timestamp REAL NOT NULL DEFAULT 0,
|
||||
token_count INTEGER,
|
||||
finish_reason TEXT,
|
||||
reasoning TEXT,
|
||||
reasoning_content TEXT,
|
||||
reasoning_details TEXT,
|
||||
codex_reasoning_items TEXT,
|
||||
codex_message_items TEXT,
|
||||
platform_message_id TEXT,
|
||||
observed INTEGER DEFAULT 0,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
compacted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def clone_hermes_session(
|
||||
source_db: Path,
|
||||
target_db: Path,
|
||||
source_session_id: str,
|
||||
target_session_id: str,
|
||||
*,
|
||||
workspace: str | None = None,
|
||||
) -> None:
|
||||
"""Clone a Hermes session from *source_db* into *target_db* under a new id.
|
||||
|
||||
Copies the ``sessions`` row (remapping ``id`` and optionally ``cwd``) and
|
||||
all ``messages`` rows (remapping ``session_id``). The target database and
|
||||
its tables are created if they don't already exist.
|
||||
|
||||
:param source_db: Path to the source Hermes ``state.db`` (opened read-only).
|
||||
:param target_db: Path to the target Hermes ``state.db`` (created if absent).
|
||||
:param source_session_id: Hermes session id in the source database.
|
||||
:param target_session_id: New session id for the cloned rows.
|
||||
:param workspace: If provided, overrides ``cwd`` on the cloned session row.
|
||||
"""
|
||||
src_conn = sqlite3.connect(f"file:{source_db}?mode=ro", uri=True)
|
||||
try:
|
||||
target_db.parent.mkdir(parents=True, exist_ok=True)
|
||||
tgt_conn = sqlite3.connect(str(target_db))
|
||||
try:
|
||||
tgt_conn.execute(_SESSIONS_DDL)
|
||||
tgt_conn.execute(_MESSAGES_DDL)
|
||||
|
||||
# Copy session row.
|
||||
row = src_conn.execute(
|
||||
"SELECT id, source, cwd, started_at FROM sessions WHERE id = ?",
|
||||
(source_session_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
_logger.warning(
|
||||
"Source hermes session %s not found in %s; skipping clone",
|
||||
source_session_id,
|
||||
source_db,
|
||||
)
|
||||
return
|
||||
_id, source, cwd, _started_at = row
|
||||
tgt_conn.execute(
|
||||
"INSERT INTO sessions (id, source, cwd, started_at) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
target_session_id,
|
||||
source,
|
||||
workspace if workspace is not None else cwd,
|
||||
# Use current time so the forwarder's started_at floor
|
||||
# discovery can find this cloned session.
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
|
||||
# Copy message rows.
|
||||
msg_rows = src_conn.execute(
|
||||
"SELECT session_id, role, content, tool_call_id, tool_calls, tool_name, "
|
||||
"timestamp, token_count, finish_reason, reasoning, reasoning_content, "
|
||||
"reasoning_details, codex_reasoning_items, codex_message_items, "
|
||||
"platform_message_id, observed, active, compacted "
|
||||
"FROM messages WHERE session_id = ? ORDER BY id",
|
||||
(source_session_id,),
|
||||
).fetchall()
|
||||
for msg in msg_rows:
|
||||
tgt_conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, tool_call_id, "
|
||||
"tool_calls, tool_name, timestamp, token_count, finish_reason, "
|
||||
"reasoning, reasoning_content, reasoning_details, "
|
||||
"codex_reasoning_items, codex_message_items, platform_message_id, "
|
||||
"observed, active, compacted) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(target_session_id, *msg[1:]),
|
||||
)
|
||||
|
||||
tgt_conn.commit()
|
||||
finally:
|
||||
tgt_conn.close()
|
||||
finally:
|
||||
src_conn.close()
|
||||
|
||||
|
||||
def bridge_dir_for_session_id(session_id: str) -> Path:
|
||||
"""Return the per-session bridge dir, e.g. ``/tmp/omnigent-<uid>/hermes-native/<hash>``."""
|
||||
digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
+46
-4
@@ -531,6 +531,7 @@ class _PiNativeLaunchConfig:
|
||||
server_url: str
|
||||
terminal_launch_args: list[str] | None
|
||||
external_session_id: str | None
|
||||
fork_source_id: str | None = None
|
||||
fork_source_external_id: str | None = None
|
||||
fork_carry_history: bool = False
|
||||
model_override: str | None = None
|
||||
@@ -728,12 +729,17 @@ async def _pi_native_launch_config(
|
||||
from omnigent.stores.conversation_store import (
|
||||
FORK_CARRY_HISTORY_LABEL_KEY,
|
||||
FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY,
|
||||
FORK_SOURCE_LABEL_KEY,
|
||||
)
|
||||
|
||||
fork_source_id: str | None = None
|
||||
fork_source_external_id: str | None = None
|
||||
fork_carry_history = False
|
||||
labels = snapshot.get("labels")
|
||||
if isinstance(labels, dict):
|
||||
_fsi = labels.get(FORK_SOURCE_LABEL_KEY)
|
||||
if isinstance(_fsi, str) and _fsi:
|
||||
fork_source_id = _fsi
|
||||
_fse = labels.get(FORK_SOURCE_EXTERNAL_SESSION_LABEL_KEY)
|
||||
if isinstance(_fse, str) and _fse:
|
||||
fork_source_external_id = _fse
|
||||
@@ -753,6 +759,7 @@ async def _pi_native_launch_config(
|
||||
server_url=os.environ.get("RUNNER_SERVER_URL", "http://localhost:6767").rstrip("/"),
|
||||
terminal_launch_args=terminal_launch_args,
|
||||
external_session_id=external_session_id,
|
||||
fork_source_id=fork_source_id,
|
||||
fork_source_external_id=fork_source_external_id,
|
||||
fork_carry_history=fork_carry_history,
|
||||
model_override=model_override,
|
||||
@@ -2407,14 +2414,49 @@ async def _auto_create_hermes_terminal(
|
||||
# cursor (clear_hermes_bridge_state above) starts it at that row's first row.
|
||||
launch_epoch_s = time.time()
|
||||
hermes_args = [*(launch_config.terminal_launch_args or [])]
|
||||
# Fork with history: resume the source Hermes session so the TUI
|
||||
# loads the prior conversation context.
|
||||
# Resolve the per-session HERMES_HOME early: the fork block below needs it
|
||||
# to place the cloned state.db, and the env block after needs it for the
|
||||
# HERMES_HOME env var.
|
||||
_hermes_home_path = read_hermes_home(bridge_dir)
|
||||
# Fork with history: clone the source Hermes session's state.db into the
|
||||
# new session's HERMES_HOME so the TUI loads the prior conversation context
|
||||
# under a fresh session id (true fork, not a shared --resume).
|
||||
if launch_config.fork_carry_history and launch_config.fork_source_external_id:
|
||||
hermes_args.extend(["--resume", launch_config.fork_source_external_id])
|
||||
from omnigent.hermes_native_bridge import (
|
||||
clone_hermes_session,
|
||||
mint_hermes_session_id,
|
||||
)
|
||||
|
||||
# Resolve the source session's state.db from its bridge dir.
|
||||
_source_bridge = (
|
||||
bridge_dir_for_session_id(launch_config.fork_source_id)
|
||||
if launch_config.fork_source_id
|
||||
else None
|
||||
)
|
||||
_source_hermes_home = read_hermes_home(_source_bridge) if _source_bridge else None
|
||||
_source_db = _source_hermes_home / "state.db" if _source_hermes_home else None
|
||||
if _source_db is not None and _source_db.is_file():
|
||||
_target_session_id = mint_hermes_session_id()
|
||||
_target_db = _hermes_home_path / "state.db" if _hermes_home_path else None
|
||||
if _target_db is not None:
|
||||
await asyncio.to_thread(
|
||||
clone_hermes_session,
|
||||
_source_db,
|
||||
_target_db,
|
||||
launch_config.fork_source_external_id,
|
||||
_target_session_id,
|
||||
workspace=workspace,
|
||||
)
|
||||
hermes_args.extend(["--resume", _target_session_id])
|
||||
_logger.info(
|
||||
"Cloned hermes session %s -> %s for fork; session=%s",
|
||||
launch_config.fork_source_external_id,
|
||||
_target_session_id,
|
||||
session_id,
|
||||
)
|
||||
# If a per-session HERMES_HOME was written (policy hook), pass it via env
|
||||
# so the TUI picks up the hook config alongside its own approval prompt.
|
||||
_hermes_terminal_env: dict[str, str] = {}
|
||||
_hermes_home_path = read_hermes_home(bridge_dir)
|
||||
if _hermes_home_path is not None:
|
||||
_hermes_terminal_env["HERMES_HOME"] = str(_hermes_home_path)
|
||||
terminal_view = await resource_registry.launch_required_terminal(
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -279,3 +281,101 @@ def test_build_spawn_env_no_hermes_home_without_policy(tmp_path, monkeypatch) ->
|
||||
monkeypatch.setattr(b, "_BRIDGE_ROOT", tmp_path / "hermes-native")
|
||||
env = b.build_hermes_native_spawn_env("test-no-policy")
|
||||
assert "HERMES_HOME" not in env
|
||||
|
||||
|
||||
# -- Session cloning tests --
|
||||
|
||||
|
||||
def _create_source_db(db_path: Path, session_id: str) -> None:
|
||||
"""Create a minimal Hermes state.db with a session and a few messages."""
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute(b._SESSIONS_DDL)
|
||||
conn.execute(b._MESSAGES_DDL)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, source, cwd, started_at) VALUES (?, ?, ?, ?)",
|
||||
(session_id, "cli", "/old/path", 1700000000.0),
|
||||
)
|
||||
# user message
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, timestamp, active) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(session_id, "user", "hello", 1700000001.0, 1),
|
||||
)
|
||||
# assistant message
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, timestamp, active) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(session_id, "assistant", "hi there", 1700000002.0, 1),
|
||||
)
|
||||
# tool message with tool_calls
|
||||
conn.execute(
|
||||
"INSERT INTO messages "
|
||||
"(session_id, role, content, tool_calls, tool_name, timestamp, active) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(session_id, "tool", "result", '[{"id":"tc1"}]', "bash", 1700000003.0, 1),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_clone_hermes_session_copies_rows(tmp_path: Path) -> None:
|
||||
source_db = tmp_path / "source" / "state.db"
|
||||
source_db.parent.mkdir()
|
||||
target_db = tmp_path / "target" / "state.db"
|
||||
|
||||
src_sid = "src-session-id"
|
||||
tgt_sid = "tgt-session-id"
|
||||
_create_source_db(source_db, src_sid)
|
||||
|
||||
b.clone_hermes_session(source_db, target_db, src_sid, tgt_sid)
|
||||
|
||||
# Target has the cloned session.
|
||||
tgt = sqlite3.connect(str(target_db))
|
||||
sess = tgt.execute("SELECT id, source, cwd, started_at FROM sessions").fetchall()
|
||||
assert len(sess) == 1
|
||||
assert sess[0][0] == tgt_sid
|
||||
assert sess[0][2] == "/old/path" # cwd preserved when workspace not given
|
||||
|
||||
# Target has all 3 messages with the new session_id.
|
||||
msgs = tgt.execute("SELECT session_id, role, content FROM messages ORDER BY id").fetchall()
|
||||
assert len(msgs) == 3
|
||||
assert all(m[0] == tgt_sid for m in msgs)
|
||||
assert msgs[0][1] == "user"
|
||||
assert msgs[1][1] == "assistant"
|
||||
assert msgs[2][1] == "tool"
|
||||
assert msgs[2][2] == "result"
|
||||
|
||||
# tool_calls preserved on the tool message.
|
||||
tool_calls = tgt.execute("SELECT tool_calls FROM messages WHERE role = 'tool'").fetchone()[0]
|
||||
assert tool_calls == '[{"id":"tc1"}]'
|
||||
tgt.close()
|
||||
|
||||
# Source is unchanged.
|
||||
src = sqlite3.connect(str(source_db))
|
||||
src_msgs = src.execute("SELECT session_id FROM messages").fetchall()
|
||||
assert all(m[0] == src_sid for m in src_msgs)
|
||||
src.close()
|
||||
|
||||
|
||||
def test_clone_hermes_session_remaps_workspace(tmp_path: Path) -> None:
|
||||
source_db = tmp_path / "source" / "state.db"
|
||||
source_db.parent.mkdir()
|
||||
target_db = tmp_path / "target" / "state.db"
|
||||
|
||||
src_sid = "src-ws"
|
||||
tgt_sid = "tgt-ws"
|
||||
_create_source_db(source_db, src_sid)
|
||||
|
||||
b.clone_hermes_session(source_db, target_db, src_sid, tgt_sid, workspace="/new/path")
|
||||
|
||||
tgt = sqlite3.connect(str(target_db))
|
||||
cwd = tgt.execute("SELECT cwd FROM sessions WHERE id = ?", (tgt_sid,)).fetchone()[0]
|
||||
assert cwd == "/new/path"
|
||||
tgt.close()
|
||||
|
||||
|
||||
def test_mint_hermes_session_id_returns_uuid() -> None:
|
||||
sid = b.mint_hermes_session_id()
|
||||
# Should be a valid UUID4 string.
|
||||
parsed = uuid.UUID(sid, version=4)
|
||||
assert str(parsed) == sid
|
||||
|
||||
Reference in New Issue
Block a user