fix(state): add immutable created_source provenance column (#56439)

Gateway /resume rewrites sessions.source to the resuming platform, which is
correct routing behavior (source feeds find_latest_gateway_session_for_peer,
Telegram listings, and the TUI ghost pruner) but destroys creation
provenance. Add a separate immutable created_source column stamped once at
session creation and preserved by all upsert paths; surface it in
hermes sessions list as '<created>→<current>' when the two diverge.

Fixes #56439
This commit is contained in:
Austin Pickett
2026-09-25 13:56:49 -04:00
parent 41cd3117ae
commit f81bb4c485
6 changed files with 45 additions and 11 deletions

View File

@@ -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(

View File

@@ -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; "<created>→<current>" 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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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(),

View File

@@ -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"