Review follow-up on the byte-identical tools[] pin.
- The pin records the code identity that built it (checkout/build sha, else
the release version). Written by the same code, every pinned tool that is
still available keeps its pinned bytes, including tools whose parameters
are derived per surface (delegate_task, text_to_speech, memory, patch).
The per-tool "parameters differ -> take current" rule replaced those bytes
on every surface hop and rewrote the ~44KB pin each time. A pin from other
code (`hermes update`, legacy name lists) takes the current definitions
once and is re-pinned.
- A pinned tool this process did not build is carried forward only while
this agent's toolset selection allows it (enabled minus disabled toolsets
and role reservations, before check_fn). It must also pass the session
schema gates on the merged array, so browser_exec never comes back once
terminal is gone. Client-surface toolsets (desktop_ui, project) still
carry across hops: no config choice removed them there.
- The rotation compaction child inherits the parent's pin in the publish
transaction.
- `hermes sessions recover` keeps pin rows in its system_prompts sweep and
clears dangling pin hashes, as lost-and-found now does too. Profile moves
carry the pin like the prompt. A continuing session whose pin is missing
or unreadable (a row swept by an older build) pins the tools it sends on
that turn, so later hops stay stable.
`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.
The per-profile store model (#88734), the parent-inheritance fence (#88381),
profile-stamped topic rows (#76423) and profile-prefixed voice keys (#75198)
are all forward-only: they put NEW state under the right profile and refuse to
widen existing damage, but nothing walks the stores and settles what earlier
releases left crossed. #113884 found 246 sessions stranded that way and could
only warn.
`hermes sessions repair-profiles` scans every profile's state.db plus the
gateway's voice-mode and sessions.json files and names six kinds of crossing:
1. `profile_name` disagreeing with the row's own session key -> relabel;
2. rows physically in another profile's store -> move (all message
generations, usage rows, system prompt) to the owning store, parents before
children so lineage survives, copy-then-delete so a crash leaves a duplicate
the next run settles;
3. `parent_session_id` crossing namespaces -> sever (own identity kept);
4. routing rows outside the default store under multiplexing -> move (an
existing row wins); routing rows for a profile that no longer exists -> drop;
5. Telegram topic bindings and voice-mode entries missing their bot's profile
-> relabel from the sessions that hold the chat (ambiguous chats reported);
6. sessions.json mirror entries for an unclaimed namespace -> drop (the legacy
import re-injects them into routing every boot).
Report-only by default. `--apply` refuses while a gateway owns any store, takes
a quick snapshot of every store first, and is idempotent. Two cases are
reported but never guessed: rows keyed to a profile that does not exist, and
`agent:main` rows inside a named profile's store (`--legacy-main rekey|move`
says which of the two histories they are).
Storage side lives in `hermes_state_profile_repair.py` (SessionDB mixin);
orchestration across stores in `hermes_cli/sessions_repair_profiles.py`; the
CLI face in `hermes_cli/sessions_cmd_repair_profiles.py` (pre-DB handler: it
opens every store itself).
Part of #88715 (PR-6). Closes the remediation gap #113884 only warns about.