diff --git a/hermes_cli/session_schema_history.py b/hermes_cli/session_schema_history.py index 588485da3f..ef5c9b4e7d 100644 --- a/hermes_cli/session_schema_history.py +++ b/hermes_cli/session_schema_history.py @@ -205,6 +205,7 @@ SCHEMA_HISTORY: dict[str, _TableHistory] = { ('26 2026-09-02T14:22Z 8e4366d358', (('+', 'tool_names', 'last_read_at'),)), ('27 2026-09-19T00:10Z 922a0c3c87', (('+', 'transport_profile', 'profile_name'),)), ('28 2026-09-25T23:25Z 2941aadffa', (('+', 'compression_overload_streak', 'compression_recovery_deadline'),)), + ('29 2026-09-27T00:29Z d75f29934b', (('+', 'created_source', 'source'),)), ), ), "messages": _TableHistory( diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py index a17459d2ba..9c4627a3cf 100644 --- a/hermes_cli/sessions_cmd.py +++ b/hermes_cli/sessions_cmd.py @@ -294,15 +294,19 @@ def _cmd_list(db, args): _title = lambda s, n: (s.get("title") or "—")[:n] # noqa: E731 _preview = lambda s, n: s.get("preview", "")[:n] # noqa: E731 _ago = lambda s: _relative_time(s.get("last_active"), session_id=s["id"]) # noqa: E731 + + def _src(s): # current routing platform; "→" when provenance diverged (#56439) + created = s.get("created_source") or "" + return f"{created}→{s['source']}" if created and created != s["source"] else s["source"] layouts = { # (has_ws, has_titles): header, rule width, row formatter (True, True): (f"{'Title':<28} {'Workspace':<18} {'Last Active':<13} {'ID'}", 110, lambda s: f"{_title(s, 26):<28} {_ws(s):<18} {_ago(s):<13} {s['id']}"), - (True, False): (f"{'Preview':<38} {'Workspace':<18} {'Last Active':<13} {'Src':<6} {'ID'}", 100, - lambda s: f"{_preview(s, 36):<38} {_ws(s):<18} {_ago(s):<13} {s['source']:<6} {s['id']}"), + (True, False): (f"{'Preview':<38} {'Workspace':<18} {'Last Active':<13} {'Src':<16} {'ID'}", 110, + lambda s: f"{_preview(s, 36):<38} {_ws(s):<18} {_ago(s):<13} {_src(s):<16} {s['id']}"), (False, True): (f"{'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}", 110, lambda s: f"{_title(s, 30):<32} {_preview(s, 38):<40} {_ago(s):<13} {s['id']}"), - (False, False): (f"{'Preview':<50} {'Last Active':<13} {'Src':<6} {'ID'}", 95, - lambda s: f"{_preview(s, 48):<50} {_ago(s):<13} {s['source']:<6} {s['id']}"), + (False, False): (f"{'Preview':<50} {'Last Active':<13} {'Src':<16} {'ID'}", 105, + lambda s: f"{_preview(s, 48):<50} {_ago(s):<13} {_src(s):<16} {s['id']}"), } header, rule, fmt = layouts[(has_ws, has_titles)] print(header + "\n" + "─" * rule) diff --git a/hermes_state_common.py b/hermes_state_common.py index 817625d112..e35e61a06a 100644 --- a/hermes_state_common.py +++ b/hermes_state_common.py @@ -362,6 +362,7 @@ CREATE TABLE IF NOT EXISTS system_prompts ( CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, source TEXT NOT NULL, + created_source TEXT, user_id TEXT, session_key TEXT, chat_id TEXT, diff --git a/hermes_state_gateway.py b/hermes_state_gateway.py index 40db740819..a15f92e6ee 100644 --- a/hermes_state_gateway.py +++ b/hermes_state_gateway.py @@ -264,11 +264,11 @@ class SessionGatewayMixin: if conn.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)).fetchone() is None: conn.execute( """INSERT INTO sessions ( - id, source, user_id, session_key, chat_id, + id, source, created_source, user_id, session_key, chat_id, chat_type, thread_id, display_name, origin_json, profile_name, transport_profile, started_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET session_key = COALESCE(sessions.session_key, excluded.session_key), chat_id = COALESCE(sessions.chat_id, excluded.chat_id), @@ -276,10 +276,11 @@ class SessionGatewayMixin: thread_id = COALESCE(sessions.thread_id, excluded.thread_id), display_name = COALESCE(sessions.display_name, excluded.display_name), origin_json = COALESCE(sessions.origin_json, excluded.origin_json), - transport_profile = COALESCE(sessions.transport_profile, excluded.transport_profile)""", + transport_profile = COALESCE(sessions.transport_profile, excluded.transport_profile), + created_source = COALESCE(sessions.created_source, excluded.created_source)""", # Same ownership stamp as _insert_session_row: an unowned (NULL) row # vanishes from profile-keyed consumers. - (session_id, source, user_id, session_key, chat_id, chat_type, thread_id, display_name, + (session_id, source, source, user_id, session_key, chat_id, chat_type, thread_id, display_name, origin_json, self._own_profile_name(), transport_profile, time.time()), ) self._execute_write(_do) diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index ce2591cf41..0b8b061d57 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -216,6 +216,9 @@ _UPSERT_KEEP_EXISTING_SQL = ",\n".join( f" {col} = COALESCE(sessions.{col}, excluded.{col})" for col in ( "session_key", "chat_id", "chat_type", "thread_id", "parent_session_id", "cwd", "profile_name", "transport_profile", "git_repo_root", "origin_json", "display_name", + # Immutable provenance (#56439): stamped once at first creation, never overwritten by later + # upserts — unlike ``source``, which stays live routing state. + "created_source", ) ) @@ -338,12 +341,12 @@ class SessionSessionsMixin: system_prompt_hash = self._store_system_prompt(conn, system_prompt) conn.execute( """INSERT INTO sessions ( - id, source, user_id, session_key, chat_id, chat_type, thread_id, + id, source, created_source, user_id, session_key, chat_id, chat_type, thread_id, model, model_config, system_prompt, system_prompt_hash, parent_session_id, cwd, profile_name, transport_profile, git_repo_root, origin_json, display_name, started_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET source = CASE WHEN sessions.source = 'unknown' @@ -382,7 +385,7 @@ class SessionSessionsMixin: END, """ + _UPSERT_KEEP_EXISTING_SQL, ( - session_id, source, user_id, session_key, chat_id, chat_type, thread_id, model, + session_id, source, source, user_id, session_key, chat_id, chat_type, thread_id, model, json.dumps(model_config) if model_config else None, system_prompt_hash, parent_session_id, cwd, profile_name, transport_profile, git_repo_root, origin_json, display_name, time.time(), diff --git a/tests/hermes_state/test_hermes_state.py b/tests/hermes_state/test_hermes_state.py index 03ba03f34c..a91af105f9 100644 --- a/tests/hermes_state/test_hermes_state.py +++ b/tests/hermes_state/test_hermes_state.py @@ -2648,6 +2648,30 @@ class TestListSessionsRich: for row in db.find_orphaned_gateway_sessions() ) + def test_created_source_preserved_across_cross_platform_resume(self, db): + """``created_source`` is immutable provenance (#56439): stamped at creation and never + rewritten by gateway peer recording, which must keep ``source`` as live routing state.""" + db.create_session("tui-sess", "tui") + db.append_message("tui-sess", "user", "created on desktop") + + # /resume from Telegram: routing state moves, provenance does not. + db.record_gateway_session_peer( + "tui-sess", source="telegram", session_key="agent:main:telegram:dm:1", chat_id="1" + ) + row = db.get_session("tui-sess") + assert row["source"] == "telegram" + assert row["created_source"] == "tui" + + # Later upserts (any surface) never clobber the stamped provenance. + db.ensure_session("tui-sess", "discord") + assert db.get_session("tui-sess")["created_source"] == "tui" + + # Self-healing insert stamps provenance from the first writer. + db.record_gateway_session_peer( + "slack-sess", source="slack", session_key="agent:main:slack:ch:2", chat_id="2" + ) + assert db.get_session("slack-sess")["created_source"] == "slack" +