fix(sessions): repair-profiles moves a session whose title the target store already holds
`hermes sessions repair-profiles --apply` copied a stranded session into its owning profile's store verbatim, title included. Titles are unique per store only (idx_sessions_title_unique), so when the target profile already held an unrelated session with the same title, which is common for generic auto-titles, the insert raised IntegrityError. _MoveBatch.run imported every row before deleting any and had no per-row handling. The exception escaped before the delete phase and before the result was memoised. Rows copied earlier in the batch were left in both stores. Every later finding re-ran the whole batch and hit the same error. Every row in the batch was reported failed on every run, the command exited 1 forever, and a non-colliding row ended up duplicated across two profiles. - import_moved_session gives a colliding title the moved row's id tail, the same convention import_foreign_history uses, capped at MAX_TITLE_LENGTH. The resident row keeps its name, so resolving it by title is unchanged. - _MoveBatch.run handles each row separately. A failed import or delete is that row's failure alone, reported through apply(). The rest of the batch still moves, and the batch runs once. The failed row's lineage waits with it: its descendants are not imported without it, and the parent it still points at in the source is not deleted. The next run moves the lineage whole. Measured on main: with two stranded rows and one title collision, both rows failed on every run and one was left in both stores. With the fix, one run moves both, and a second run finds nothing.
This commit is contained in:
committed by
Teknium
parent
ba5bec3f2c
commit
3dd848f263
@@ -201,7 +201,10 @@ class SessionProfileRepairMixin:
|
||||
"""Insert a moved session into THIS store as *profile_name*'s. ``present`` when the id already
|
||||
exists (an earlier run copied but did not delete), else ``imported``. The parent link survives
|
||||
only when the parent is already here — a moved row must never point across stores or at a
|
||||
row of another profile. Columns the target schema lacks are dropped, never invented."""
|
||||
row of another profile. Columns the target schema lacks are dropped, never invented. Titles
|
||||
are unique per store only, so a title an unrelated row here already holds gets the moved
|
||||
row's id tail (the :meth:`import_foreign_history` convention); the resident row keeps its
|
||||
name, since it is the one this profile's clients resolve by title."""
|
||||
session = dict(payload["session"])
|
||||
session_id = session["id"]
|
||||
|
||||
@@ -212,6 +215,10 @@ class SessionProfileRepairMixin:
|
||||
parent_id = session.get("parent_session_id")
|
||||
if parent_id and conn.execute("SELECT 1 FROM sessions WHERE id = ?", (parent_id,)).fetchone() is None:
|
||||
session["parent_session_id"] = None
|
||||
title = session.get("title")
|
||||
if title is not None and conn.execute("SELECT 1 FROM sessions WHERE title = ?", (title,)).fetchone():
|
||||
suffix = f" ({session_id[-12:]})"
|
||||
session["title"] = title[:self.MAX_TITLE_LENGTH - len(suffix)] + suffix
|
||||
session["system_prompt_hash"] = self._store_system_prompt(conn, payload.get("system_prompt"))
|
||||
self._insert_row(conn, "sessions", session, skip=frozenset())
|
||||
for message in payload.get("messages") or []:
|
||||
|
||||
Reference in New Issue
Block a user