Compare commits

...

4 Commits

Author SHA1 Message Date
Tomu Hirata d530a0eaaa fix(hermes-native): use current time for cloned session started_at
The forwarder discovers sessions by started_at >= launch_epoch_s. The
cloned session copied the source's old started_at, so it fell below
the floor and was never found — blocking message injection and mirroring.

Also removes debug logging from the previous commit.

Co-authored-by: Isaac
2026-06-26 18:05:01 +09:00
Tomu Hirata 5dc56b9f3a debug: log PATCH failure at warning level + fork check fields
Co-authored-by: Isaac
2026-06-26 17:59:02 +09:00
Tomu Hirata 066b7635ec debug: log fork check fields 2026-06-26 17:45:27 +09:00
Tomu Hirata f8def2dfb6 feat(hermes-native): implement true fork via session cloning
Replace the simple --resume approach for hermes-native forks with a
true session clone: mint a fresh Hermes session id, copy the source
session's state.db rows (sessions + messages) into the fork's
HERMES_HOME, and --resume the cloned id. This gives each fork its
own independent conversation history.

- Add mint_hermes_session_id() and clone_hermes_session() to
  hermes_native_bridge.py
- Add fork_source_id to _PiNativeLaunchConfig and wire it through
  _pi_native_launch_config (reads FORK_SOURCE_LABEL_KEY)
- Update _auto_create_hermes_terminal() to clone instead of sharing
- Add tests for clone, workspace remapping, and UUID minting

Co-authored-by: Isaac
2026-06-26 17:27:52 +09:00
3 changed files with 267 additions and 4 deletions
+121
View File
@@ -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
View File
@@ -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(
+100
View File
@@ -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