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:
@@ -205,6 +205,7 @@ SCHEMA_HISTORY: dict[str, _TableHistory] = {
|
|||||||
('26 2026-09-02T14:22Z 8e4366d358', (('+', 'tool_names', 'last_read_at'),)),
|
('26 2026-09-02T14:22Z 8e4366d358', (('+', 'tool_names', 'last_read_at'),)),
|
||||||
('27 2026-09-19T00:10Z 922a0c3c87', (('+', 'transport_profile', 'profile_name'),)),
|
('27 2026-09-19T00:10Z 922a0c3c87', (('+', 'transport_profile', 'profile_name'),)),
|
||||||
('28 2026-09-25T23:25Z 2941aadffa', (('+', 'compression_overload_streak', 'compression_recovery_deadline'),)),
|
('28 2026-09-25T23:25Z 2941aadffa', (('+', 'compression_overload_streak', 'compression_recovery_deadline'),)),
|
||||||
|
('29 2026-09-27T00:29Z d75f29934b', (('+', 'created_source', 'source'),)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"messages": _TableHistory(
|
"messages": _TableHistory(
|
||||||
|
|||||||
@@ -294,15 +294,19 @@ def _cmd_list(db, args):
|
|||||||
_title = lambda s, n: (s.get("title") or "—")[:n] # noqa: E731
|
_title = lambda s, n: (s.get("title") or "—")[:n] # noqa: E731
|
||||||
_preview = lambda s, n: s.get("preview", "")[: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
|
_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
|
layouts = { # (has_ws, has_titles): header, rule width, row formatter
|
||||||
(True, True): (f"{'Title':<28} {'Workspace':<18} {'Last Active':<13} {'ID'}", 110,
|
(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']}"),
|
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,
|
(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} {s['source']:<6} {s['id']}"),
|
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,
|
(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']}"),
|
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,
|
(False, False): (f"{'Preview':<50} {'Last Active':<13} {'Src':<16} {'ID'}", 105,
|
||||||
lambda s: f"{_preview(s, 48):<50} {_ago(s):<13} {s['source']:<6} {s['id']}"),
|
lambda s: f"{_preview(s, 48):<50} {_ago(s):<13} {_src(s):<16} {s['id']}"),
|
||||||
}
|
}
|
||||||
header, rule, fmt = layouts[(has_ws, has_titles)]
|
header, rule, fmt = layouts[(has_ws, has_titles)]
|
||||||
print(header + "\n" + "─" * rule)
|
print(header + "\n" + "─" * rule)
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ CREATE TABLE IF NOT EXISTS system_prompts (
|
|||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
source TEXT NOT NULL,
|
source TEXT NOT NULL,
|
||||||
|
created_source TEXT,
|
||||||
user_id TEXT,
|
user_id TEXT,
|
||||||
session_key TEXT,
|
session_key TEXT,
|
||||||
chat_id TEXT,
|
chat_id TEXT,
|
||||||
|
|||||||
@@ -264,11 +264,11 @@ class SessionGatewayMixin:
|
|||||||
if conn.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)).fetchone() is None:
|
if conn.execute("SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)).fetchone() is None:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO sessions (
|
"""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,
|
chat_type, thread_id, display_name, origin_json,
|
||||||
profile_name, transport_profile, started_at
|
profile_name, transport_profile, started_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
session_key = COALESCE(sessions.session_key, excluded.session_key),
|
session_key = COALESCE(sessions.session_key, excluded.session_key),
|
||||||
chat_id = COALESCE(sessions.chat_id, excluded.chat_id),
|
chat_id = COALESCE(sessions.chat_id, excluded.chat_id),
|
||||||
@@ -276,10 +276,11 @@ class SessionGatewayMixin:
|
|||||||
thread_id = COALESCE(sessions.thread_id, excluded.thread_id),
|
thread_id = COALESCE(sessions.thread_id, excluded.thread_id),
|
||||||
display_name = COALESCE(sessions.display_name, excluded.display_name),
|
display_name = COALESCE(sessions.display_name, excluded.display_name),
|
||||||
origin_json = COALESCE(sessions.origin_json, excluded.origin_json),
|
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
|
# Same ownership stamp as _insert_session_row: an unowned (NULL) row
|
||||||
# vanishes from profile-keyed consumers.
|
# 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()),
|
origin_json, self._own_profile_name(), transport_profile, time.time()),
|
||||||
)
|
)
|
||||||
self._execute_write(_do)
|
self._execute_write(_do)
|
||||||
|
|||||||
@@ -216,6 +216,9 @@ _UPSERT_KEEP_EXISTING_SQL = ",\n".join(
|
|||||||
f" {col} = COALESCE(sessions.{col}, excluded.{col})" for col in (
|
f" {col} = COALESCE(sessions.{col}, excluded.{col})" for col in (
|
||||||
"session_key", "chat_id", "chat_type", "thread_id", "parent_session_id", "cwd", "profile_name",
|
"session_key", "chat_id", "chat_type", "thread_id", "parent_session_id", "cwd", "profile_name",
|
||||||
"transport_profile", "git_repo_root", "origin_json", "display_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)
|
system_prompt_hash = self._store_system_prompt(conn, system_prompt)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO sessions (
|
"""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,
|
model, model_config, system_prompt, system_prompt_hash,
|
||||||
parent_session_id, cwd, profile_name, transport_profile, git_repo_root,
|
parent_session_id, cwd, profile_name, transport_profile, git_repo_root,
|
||||||
origin_json, display_name, started_at
|
origin_json, display_name, started_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
source = CASE
|
source = CASE
|
||||||
WHEN sessions.source = 'unknown'
|
WHEN sessions.source = 'unknown'
|
||||||
@@ -382,7 +385,7 @@ class SessionSessionsMixin:
|
|||||||
END,
|
END,
|
||||||
""" + _UPSERT_KEEP_EXISTING_SQL,
|
""" + _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,
|
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,
|
parent_session_id, cwd, profile_name, transport_profile, git_repo_root, origin_json,
|
||||||
display_name, time.time(),
|
display_name, time.time(),
|
||||||
|
|||||||
@@ -2648,6 +2648,30 @@ class TestListSessionsRich:
|
|||||||
for row in db.find_orphaned_gateway_sessions()
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user