_insert_message_rows stamps _row_id and the stored-row digest onto the
caller's dicts inside the write transaction. Only append_messages_batch
restored that state on rollback. archive_and_compact, replace_messages
and the rotation handoff left the rolled-back id + digest on the dicts;
SQLite reuses the id, so a later flush found a digest mismatch on the
foreign row, adopted it and silently dropped the user's message.
Move the capture/restore into _execute_transcript_write, used by every
caller that inserts caller-owned dicts: each attempt starts from the
caller's state and a final failure restores it before re-raising.
(Rewind replacement and import insert dicts built inside the txn.)
Also: bind _message_row_params directly on insert instead of the
serialized-dict round-trip, import the public DB_ROW_SNAPSHOT /
CANONICAL_ROW names, set adopt=False once, and reuse target_row
instead of re-SELECTing when nothing was written.
A legacy (no-digest) dict over a non-blank assistant row adopted the whole
decoded DB row: tool_calls / reasoning* / codex_* were overwritten with the
stored JSON (which still holds the escaped lone surrogate the sanitizer just
fixed, re-injecting it into the provider payload) and live-only fields were
popped. Resumed dicts (_rows_to_conversation stamps _row_id without a
digest) and compaction clones hit this path. Adopt content only, as before
this stack, via a content-only canonical handled like the metadata-only one.
_insert_message_rows dropped a clone's parent digest but only the flush
path restamped it, so clones made by archive_and_compact / replace /
rotation handoff / import reached the legacy path and the first live edit
after a clone was not persisted. Stamp the stored-row digest inside
_insert_message_rows (one batched SELECT, cold paths only; the flush path
statement count is unchanged) and drop the duplicate call in
append_messages_batch.
Define the _db_row_snapshot / _canonical_row keys once in
agent/message_metadata.py and import them everywhere instead of repeating
the literals.
The row digest hashed every repair column, so a same-process metadata write
(reaction, display-kind stamp, api_content / codex reasoning backfill,
platform message id) made our own row look like a foreign winner. The
re-flush then adopted the stale DB row: a later live edit (the non-ASCII
strip recovery) was reverted, and unsanitized tool_calls/reasoning were
copied back onto the live dict.
The digest now covers only the owned (non-metadata) columns: it means "the
row is still what we last committed". Match -> write the live owned values
and hand over only presentation metadata the live dict lacks; mismatch ->
genuine other writer, adopt as before. The r3 "stored content equals the
durable form of live" special case is subsumed and removed.
Also:
- _insert_message_rows drops a carried digest when it assigns a new row id
(compaction/replace/import clones carried the parent's version).
- append_messages_batch restores each message's _row_id / digest /
timestamp and pops the adopted row at the top of every _execute_write
attempt, so a rolled-back attempt cannot resolve to a foreign row.
- message_id is no longer synced onto live (int -> str flip, spurious
platform_message_id).
- The JSONL divert strips both bookkeeping keys via one frozenset.
The row-addressed repair stamped the decoded durable row on every
resolved message, so after our own rewrite the sync copied the lossy
durable projection (image parts -> "text\n[screenshot]") back onto the
live dict: multimodal user/tool messages lost their images and the
prompt-cache prefix changed. Adopt the DB row only when another writer
won (digest mismatch) or on the legacy assistant path, as BASE did.
The insert-time digest hashed Python bind values, but SQLite affinity
rewrites them on storage (int message_id -> TEXT, float token_count ->
INTEGER), so live and DB digests never matched and in-place edits were
silently dropped. Hash the stored rows instead, only on the
append_messages_batch flush path that reads the digest (one SELECT per
batch), incrementally (type tag + length prefix) instead of via JSON.
Also skip the no-op UPDATE, fix the _write_columns comment/spacing and
drop the duplicate top-level Optional import (F811).
The CAS row snapshot was a full copy of each message's durable payload
riding on the live dict. The rough token estimator priced it (about 2x
estimates -> premature compaction) and it doubled transcript memory.
Replace it with a 16-byte blake2b digest of the repair columns. The
compare now runs in Python against the target row already read inside
the BEGIN IMMEDIATE transaction, followed by a plain UPDATE. Also:
- add _db_row_snapshot to PERSISTENCE_ONLY_MESSAGE_FIELDS so the
estimator and the outbound request builder both drop it
- derive _REPAIR_COLUMNS/_SYNC_FIELDS from _MESSAGE_WRITE_COLUMNS
- use hermes_state_common._placeholders
- drop the dead resume-path stamp (the SELECT has no token_count, so it
was always None) and the dead tool name assignment in
_decoded_repair_row
- keep the digest out of divert JSONL
The kept active-row test now pins estimate stability across a flush and
the survival of a concurrent writer's row. It goes red on the old
prod files and red when the digest compare is removed.
A compaction generation that computed the display identity differently
leaves one logical message in two display_order groups, and GROUP BY
display_order then projects it twice (issue 122167 symptom B). Reconcile
by the recomputed display key before publishing, in both commit paths.
Related to #122167
(cherry picked from commit fb3b9ac0591f9cfc4b06bd8561331d2ea51eaced)
import_sessions inserted every row live and pruned shadowed checkpoints before restoring the archived flags. A newer archived carrier (a rewound turn) then stripped the newest live checkpoint, and every archived row lost its own. Rows written before #102374 pruning each carry one, so adopting such a donor fed the model a context without its checkpoint while the donor was retired as unrecoverable.
The import now prunes once the archived rows are archived again, keyed on the live rows only; every other writer still prunes on insert. The blank line at the end of the adoption test file is dropped.
(cherry picked from commit f9ad62b37517cbbdc27f75e9e58900048258bd7b)
A prompt accepted while the agent was busy lived only in the in-memory
queue: session.resume's cold read lacked it until its turn ran and a
backend restart lost it permanently. _handle_busy_submit now writes the
user row through the same #111868 machinery as an idle submit (extracted
as _write_submit_user_row; the durable dict rides the QUEUE ENVELOPE,
never the shared session slot the in-flight turn may own), a text-only
merge syncs the already-written row's content in place, and
_drain_queued_prompt re-places the row at the transcript end before
dispatch (fresh write + deactivate_message on the early row) so the
stored raw order stays [u, a, u, a] instead of glueing the two user
turns under repair_alternation, and the drained turn adopts the fresh
row instead of appending a duplicate. Cancel/crash keep the trailing
user row (the documented interrupted shape). display_kind rides the
envelope from prompt.submit through both writes.
(cherry picked from commit 32792f99d582608d68d40bc12930b295af0d332e)
A gap below the newest held id, and turns appended above an unpersisted current
turn, were summarized away without being read. Name those held ids and clone
the rest.
Superseding a stale micro marker joins the now-adjacent user turns into one
model-facing row, while the originals stay in display history as compacted
rows, so resumed display history painted every merged input twice (and one
more time per later pass). Flag the join display_metadata.model_only and skip
it in every display projection: resume dedupe, the indexed and legacy
get_messages pages, and the prompt timeline. The model payload is unchanged.
The "display index not backfilled" probe was spelled twice in
hermes_state_messages.py, and the copy in the delete fence tested only
display_order while _ensure_display_order tests display_order OR
display_identity. Hoist one _DISPLAY_INDEX_MISSING_SQL beside
_DISPLAY_ACTIVE_CLAUSE and use it at both sites. The fence now refuses
whenever the read path would have backfilled instead of projecting; on
every reachable delete the two probes agree (the read path backfills both
halves before the export snapshot exists), so this only tightens fail-closed.
hermes_state_timeline keeps its own probe: it carries a role slot.
_cmd_export re-derived "which formats are human-read transcripts" as
`format == "html" or only`; use SAVE_TRANSCRIPT_FORMATS instead. Equivalent
on every reachable path: md/qmd without --only never reach _collect_sessions
(they route to _export_markdown), and --only forces the transcript view.
Bind include_compacted once in a local `_one` instead of threading it.
The IOERR test now injects on "WITH page AS", the display CTE's own opener,
so reshaping the SQL cannot let _ensure_display_order's SELECT probe absorb
the failure and make the case pass vacuously. Comments that narrated
removed guards now state the current reason.
The get_messages(include_compacted=True) extraction into
_display_rows_from_conn swapped `self._read_all(sql, params)` for a bare
`with self._read_ctx() as conn:`. _read_all routes through
_read_retrying_ioerr, which replays the SELECT on the same pooled mode=ro
reader across the WAL-transition `disk I/O error` window (#100871);
_read_ctx has no retry, so the TUI transcript load and every transcript
export would have surfaced a hard OperationalError where base recovered.
Route the display projection through _read_retrying_ioerr again. The
existing #100871 test file gains a get_messages case parametrised over
both read paths; the display CTE starts with WITH, so the flaky reader
gets a configurable statement prefix to target it.
The common micro-compaction pass carries dicts that all hold a matching
_row_id, yet _resolve_carried_row_ids re-read and re-derived identity for
every active row in the session inside the write transaction, O(active
rows) per turn on long sessions. Narrow the identity query to the carried
ids in that case and keep the full active-row scan for the fallbacks
(missing ids, or a stale id whose stored identity no longer matches), so
resolution semantics are unchanged.
Micro-compaction carries a non-contiguous prefix and suffix around its summary marker. Using tail_count=len(result)-1 incorrectly marked summarized assistant/tool rows as rewind-only active=0, compacted=0.
Pass the exact unchanged carried messages instead and resolve their durable originals transactionally by row id or unique identity+timestamp, preserving summarized rows as compacted history.
Fixes#118481
#116756 made `_row_to_message_dict` pop any bytes/bytearray value so a BLOB
column added to `messages` by a later build cannot break the JSON encoder
that serves transcripts over HTTP. The pop ran over every column, including
the schema's own: a row whose `content` (or `role`, `tool_call_id`) holds a
BLOB lost the key entirely, and every downstream reader that indexes
msg["content"] — resume, compaction, display — raised KeyError where it
previously received the raw value.
Restrict the pop to keys outside `_MESSAGE_SCHEMA_KEYS` (the columns this
module writes, derived from `_INSERT_MESSAGE_SQL`, plus id/compacted/
display_order). An unknown BLOB column is still dropped; a known column keeps
its key and its typed decoder, so the dict shape never depends on a value.
Follow-up to #116756 (independent review finding).
Efficiency gate: replace_messages had swapped the free insert-return counters for a full
active-set rescan (JSON parse per row) on every branch, slowing the untouched compress/ACP
path; the counts are now kept-prefix (from the fetched rows) + inserted. The identity SELECT
is bounded to len(messages)+1 rows — the prefix plus the row that anchors the archive UPDATE.
_row_identity is the one place the compared columns are spelled; _kept_live_prefix became
_stamp_kept_live_prefix (it mutates _row_id); rewind's _comparison_content delegates to the
shared _loaded_view_content.
Gate review: _rows_to_messages sanitizes and strips user/assistant text on load, so a rewind
issued from a reloaded session compared stripped content against raw rows, missed at the first
whitespace-trailing turn, and fell back to archive-all + re-insert once per reload. Both sides
now go through _loaded_view_content (shared with the loader). Test: rewind after a reload keeps
the prefix ids (red on the previous head).
replace_messages(archive_dropped=True) archived every live row and re-inserted
the whole kept prefix as fresh rows, so each rewind/edit grew the active=0
archive by the full transcript (#82956). Match the in-order live prefix on the
identity _insert_message_rows writes, archive only from the first divergent
live row (the shape rewind_to_message already uses), insert only the new
suffix, and stamp kept messages with their existing _row_id. Counters come
from the active transcript, not the inserted batch.
SELECT * in every message reader (get_messages, get_messages_around)
hands every column straight into a dict that FastAPI serializes to
JSON. FastAPI's encoder calls .decode() on any raw bytes value and
raises UnicodeDecodeError the moment it isn't valid utf-8 -- this
already happened for display_identity BLOB before it got an explicit
pop, and the next binary column added to the messages table would
repeat it with no defense in the reader.
_row_to_message_dict now strips any remaining bytes/bytearray value
generically, so a future BLOB column can't take the whole endpoint
down regardless of whether its pop was remembered.
Fixes#116510
`Fixes #102374` was only true for the compacted transcript: the prune ran
inside local `compress()` / `salvage_grown_transcript`, and under native
compaction the server compacts LOCAL_TRIGGER_SAFETY_MARGIN below the local
trigger, so local compaction rarely fires. Every assistant response still
persisted its ~120 KB checkpoint to `messages.codex_reasoning_items` and the
older rows' shadowed copies were never rewritten — the field path in the
issue (1,967 checkpoint rows, 12.9 GiB in one lineage).
`SessionDB._insert_message_rows` (the one INSERT path: append batch,
replace, compact, import) now rewrites the older ACTIVE assistant rows of the
same session to drop their `type: "compaction"` items once a newer carrier
row lands, keeping every non-checkpoint item; wire-neutral because
`prune_pre_checkpoint_items` never replays a shadowed checkpoint. The flush
mirrors it on the live transcript (`drop_shadowed_checkpoints`) so forks and
compaction built from memory carry one checkpoint too, and the marker
contract holds: the dicts read exactly as their rows.
set/get used only the lineage filter while take_unseen also required
(active = 1 OR compacted = 1), so a rewound row could be reacted to but never
announced; _DISPLAY_META_ROW_SQL now carries the shared _DISPLAY_ACTIVE_CLAUSE.
take_unseen_reactions scans the whole lineage each turn, so it now filters on
json_extract(display_metadata, '$.reactions') in SQL instead of decoding every
metadata-bearing row in Python. Tests share the compacted-lineage fixture.
A display resume materializes the whole compression lineage with row ids
(`get_resume_conversations(include_ancestors=True)`), so the desktop shows —
and lets the user react to — rows that live in an ended parent segment. The
gateway's `session_key` is re-anchored to the continuation after every
compaction, and `set_message_reaction` scoped the row by that exact key, so
every reaction on a pre-compaction message returned None and the desktop
surfaced RPC 4040 "message not found in this session" (#80670: the 802
compacted-row repro; the agent-side `react_to_message` tool hit the same
wall in #108633).
`set_message_reaction` / `get_message_reactions` / `take_unseen_reactions`
now scope by `_resume_lineage_ids(session_id)` — the same set the resume
loads, so an explicit /branch copy still owns only its own rows and an
unrelated session's row stays foreign. The RPC handler and the tool are
unchanged: ownership is decided once, at the row.
Lineage ownership was first identified in #108635 by @KoNit-K (tool path);
the compacted-row half of the unseen-reaction scan is @Liuzikaii's #108542,
cherry-picked ahead of this commit.
The SQL chain step (#114287) stopped a `_reset_from` child of a compression-ended parent
from winning tip projection. The Python twin had the same blind spot:
`_is_compression_child_row` / `_compression_lineage_root` treated the reset fork as a
continuation, so `get_compression_lineage(tip)` collapsed to `[tip]` (ancestors lost for
prompt-cache scope and export) and the fork shared the lineage's turn-lease key. Both now
ask `_is_explicit_fork_child_row(include_reset=True)`; `get_compression_lineage`'s own
early return keeps excluding only branch/delegate/tool so a reset child that later
compresses still walks forward to its children.
Gateway bare `/resume` lists with `order_by_last_active=True`: a lineage compressed for
days is projected onto its live tip and belongs where the user last touched it, not at
its root's `started_at` (the reporter's tip, active yesterday, was buried under a
September-12 start). Desktop already requests `order=recent`.
Docs: `/resume` row in slash-commands reference. Tests: one lineage-walk invariant, one
/resume ranking invariant, both red on origin/main.
Part of #114271
Squash of the 54 commits on victor-kyriazakos:feat/user-channel-warning-suppression
(PR #112302, head f45c640e55) so the contributor's authorship survives a rebase-merge;
the commits interleave with a cron delivery-ledger rework that the salvage removes in
follow-up commits, so per-commit cherry-picks were not practical.
Adds display.suppress_warning_notifications (global + per-platform, default false):
one resolver (gateway/warning_notifications.py), BasePlatformAdapter.emit_warning /
emit_media_warning / warning_text, a notification_category classification carried
through wakes, queues and persistence, and render/present boundaries for CLI/TUI.
Two data-loss paths around Desktop sessions (#111868).
A. prompt.submit wrote the session row at send but the user's message only once
the agent finished building, so quitting a frozen app during a slow first build
left an empty session with no message. The message is now appended right after
the row (_persist_submit_user_row) and staged on the session already stamped
durable; the turn hands it to the agent as _pending_cli_user_message, which
_stage_turn_user_message adopts by identity so the crash persist and the
turn-end flush write no second row. A prompt the prologue rewrote (@-expansion,
image parts) updates that row first (SessionDB.set_user_message_content) so the
durable transcript replays what was sent and the api_content sidecar can address
it. A turn cancelled before the agent was ready drops the staged dict with the
inflight turn so a later turn cannot adopt it.
B. A profile rename left tabs, Bot tile owner routes, cached transcript tails,
the remembered session/route and session owner hints keyed by the old profile
name, so every restored tab dialed a backend that no longer existed and looped on
"Couldn't open this session". migrateTilesForProfile(old, new) — the rename
sibling of dropTilesForProfile — moves every family to the new name; the rename
dialog calls it once the backend rename succeeded (local, non-default).
Two rewind tests asserted the durable transcript between submit and turn; they
now include the prompt just sent, which is the new guarantee.
Review findings on the salvage (all reproduced with a real SessionStore):
1. Primary persisted-agent path skipped the boundary. The agent's turn-start
flush already persists the user row stamped with the inbound platform id, so
`has_platform_message_id` saw THIS turn's own row, took the "duplicate" branch
and skipped the whole block — including the new assistant boundary. The
transcript stayed `[..., 'user']`, exactly the open tail #107070 is about.
Fresh sessions hid it a second way: `session_meta` is appended after the
agent-flushed user row, so a naive "newest row" tail read sees `session_meta`.
2. The exception fallback appended the boundary unconditionally; a redelivery of
an already-closed turn produced `['user', 'assistant', 'assistant']`.
3. The exception fallback wrote the user row + boundary before classifying a
400/500-on-long-session as overflow, growing a session that is already too
large (the #1630 no-grow rule the persist path honours).
Fix: `SessionDB.latest_conversation_role()` (newest active row excluding the
`session_meta`/`system` bookkeeping rows the model never sees) behind
`SessionStore.transcript_tail_role()`, which resolves the same route
`load_transcript` reads via the existing `_compression_tip_for_session_id`.
One `_hmwa_close_failed_turn()` appends the boundary iff that tail is an open
user row; both the persist path and the exception fallback call it, so the
user-row dedupe no longer gates the boundary and a redelivery never stacks.
The overflow verdict in `_hmwa_agent_error_reply` is an early return ahead of
every transcript write. The `failed_turn_notice` kwarg and its dead
`or _hmwa_failed_turn_notice(...)` fallback are gone; the notice is derived
where each consumer needs it.
Tests (each red with the production change reverted, green here): boundary
keyed on the durable tail with the user write deduped (agent-flushed row →
closed; redelivery → nothing); fresh-session agent-flushed failed first turn
closed despite `session_meta` (real store); exception-path redelivery adds no
second boundary (real store, every lineage location, contract asserted from
store state); exception-path overflow persists nothing. Live E2E:
`evals/gateway_failure_ownership/probe.py` (real AIAgent + fixture provider)
20/20; the two `failed provider input` turns that previously left an open user
tail now close with the "not processed" row.
SQLite dynamic typing lets a TEXT cell ('not-a-timestamp'), inf/nan or a
garbage double (8.4e252 salvaged from a damaged page) sit in a REAL
timestamp column. Every reader called datetime.fromtimestamp()/float
arithmetic on the raw cell, so ONE bad row raised TypeError/OverflowError
out of the row loop and took down the whole `hermes sessions list`/browse
table (#102399), all three exporters — JSONL/MD, QMD, HTML (#102352) —
and `hermes insights` (#99959).
Fix the class with ONE helper, hermes_cli.timefmt.coerce_epoch(): a
stored cell becomes float epoch seconds inside a sane 1970..2103 window
or None after a WARNING that names the session id. Every reader routes
through it — relative_time (list/browse/resume picker), format_epoch
(prune/candidates tables), the three exporters' timestamp formatters,
insights' _get_sessions/_day/period range — so a bad row renders as
'?'/'N/A'/raw text for that one cell and the command completes.
Write side: hermes_state_messages._coerce_timestamp (append_message,
append_messages_batch, import) and the import path's started_at now use
the same window, so a new out-of-range timestamp falls back to now()
instead of being persisted — new bad rows cannot be written by Hermes.
Reported-by: #102399, #102352, #99959 reporters; kokhlo's insights
analysis pointed at every reporting site, not just line 860.
The api_content sidecar ('persist what you send') preserves prompt-cache
stability across turn boundaries by persisting the exact API-bound bytes
(including memory-manager prefetch, plugin injections, and API-only notes)
and substituting them on replay.
When a user turn was already materialized in the database before the
sidecar could be composed (in-place preflight compaction or a close/early
flush racing the prologue on the CLI path), the turn-start crash persist
marker-skips that message. Previously, the backfill was gated strictly on
in-place compaction (_preflight_compressed and _last_compaction_in_place),
so racing CLI flushes left api_content = NULL in SQLite and broke prompt
caching on subsequent turns (#102194).
Positional approaches (such as #102239 and #102286) using LIMIT 1 on the
newest active user row are unsafe: repeated common inputs ('ok', 'yes',
'continue') cause the backfill to match and overwrite the PREVIOUS turn's
row with the new turn's sidecar, corrupting history and breaking cache parity.
Resolve all landing blockers and review feedback from #102411:
1. Bounded state owner (Sahilvishnaliya):
Add SessionDB.set_message_api_content(session_id, row_id, content, api_content)
to SessionMessagesMixin in hermes_state_messages.py instead of growing
hermes_state.py. Update set_latest_user_api_content docstring with durable
warning on the positional hazard.
2. API-only turns & durable content selection (ehz0ah):
When a pre-flushed clean input has an API-only difference (e.g. voice
prefix or model-switch note):
- Retain the differing API-facing bytes as api_content even when no
new memory or plugin context was injected.
- Derive the durable content guard using _override_replaces_content so
the SQL 'content IS ?' guard matches the clean override text stored
in the DB row rather than the restored wire text.
3. Turn prologue gating (_row_id) & fail-closed store duck-typing (ehz0ah):
In agent/turn_context.py::_stamp_api_content_sidecar: check _row_id on
the live user dict (stamped by _insert_message_rows and synced by
sync_flushed_message_markers). If valid (positive int, not bool), address
by exact ID. Do NOT fall back to positional matching when a row ID is
present: if an external or custom wrapper lacks set_message_api_content,
fail closed and skip rather than corrupting a neighbouring row. If absent
but in-place compacted, fall back to positional update. On normal turns,
skip the backfill entirely (single atomic INSERT).
4. Real lifecycle test coverage (salch-cred, ehz0ah):
Comprehensive tests in tests/agent/test_api_content_row_addressed_backfill.py
covering store guards, surrogate scrubbing, gate non-arming, older identical
row protection, real close-flush row_id synchronization, API-only clean
override preservation with exact wire replay, and duck-typed store fail-closed
verification when set_message_api_content is absent.
Fixes#102194.
Closes#102411.
Namespace delivery markers and assign fresh keyless turn identities instead
of inferring ownership from IDs or process-local row baselines. Query only
marker existence on the canonical live compression continuation and ancestors.
Preserve raw reply IDs and exclude metadata from provider wire messages.
Expand the two existing invariants with resumed cross-chat ID collisions,
a real independent SQLite writer, reaped siblings, and archived-history
allocation controls. All 20 full-handler checkpoints and 63 targeted tests pass.
_dedupe_display_generations chose the right representative row per logical
message but sorted the survivors by that representative's id. A protected-tail
copy written into a newer compaction generation has a higher id than messages
emitted after the original, so include_compacted reads came back as C, A, B.
Anchor the sort on the logical message's first-ever row id instead.
Salvaged from #93869 (the tui_gateway half of that PR is superseded by #100504
and #104137); the code moved from hermes_state.py to hermes_state_messages.py
since, so the change is re-applied to its new home with the PR's regression
test verbatim.
hermes_state.py: delete every '# noqa: F401 (re-exported...)' import block (hermes_state_common/errors/guard/
readpool/sessions/fts/dbfile/wal/repair/registry + agent.context_compressor _DB_PERSISTED_MARKER_KEY); keep
only the names hermes_state.py itself uses, without noqa.
hermes_state_registry.py: drop get_shared_session_db/release_shared_session_db/close_shared_session_dbs
aliases; every caller (gateway/, tools/, tui_gateway/, cron/, mcp_serve, run_agent, tests) now imports
acquire/release/close_all/release_or_close from hermes_state_registry.
hermes_state_titles.py: drop set_auto_title_if_empty shim (title_generator keeps its getattr fallback).
Re-remove shim-only names restored by 34abf954bd: latest_user_message_row_id (tests call
latest_message_row_id(key, role='user'); role-targeting assertions kept) and get_session_activity (tests
build the snapshot via agent.session_activity.build_activity_snapshot over db.get_session(sid)).
hermes_state_wal._log_once resolves its dedupe sets as module globals instead of via hermes_state;
hermes_state_repair helpers call module globals directly (tests patch hermes_state_repair.<name>).
Frozen updater surface untouched (update_cmd_maint imports only SessionDB from hermes_state).
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.
All public on BASE 63279301bc, dropped by the simplify refactor (their tests were deleted or
rewritten to the replacement API). Restore each with BASE signature/body as a thin wrapper over the
surviving implementation, and restore the tests at the original call sites: test_message_reactions
again asserts the role=user contract (a newer assistant message is never the default target);
test_hermes_state / test_watchdog_review_76354 go back to get_session_activity(); toolsets, acp auth,
edit_approval, billing-scope and curated-models tests restored/extended.