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:
John Paul Soliva
2026-09-23 07:49:20 +09:00
committed by Teknium
parent ba5bec3f2c
commit 3dd848f263
3 changed files with 140 additions and 7 deletions

View File

@@ -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 []: