Greptile on df9746d5f09:
- A chat handed off to a route again after another one took it (H, then Y,
then H) skipped the supersede loop because it was already on that route, so
Y stayed writable while the route named H. _publish_route now treats a
superseded session coming back as a takeover too.
- The Bot Chat a delivery minted under its automatic creation id, reset
locally and handed off, leaves its first segment untitled on the lane once
the name follows the head. _bot_chat_creation_id read that untitled row as an
interrupted creation and reused its id after the platform's /new, so the next
delivery hit the released receipt (storage_unavailable). A creation handed
off to a messaging lane is spent.
- A moved receipt whose lineage holds a non-string entry raised TypeError in
the boot heal and stopped the profile's start; hand_off_conversations now
leaves such a receipt as it is (its own reads refuse it, logged).
- The title follow-the-head rule ranks provenance with SessionDB._title_rank
instead of a copy (a pre-provenance title ranks as the user's).
Three defects an independent re-review found on #144708 (#143286):
- Data safety. After a local /new (receipt now names the new segment), a
/handoff of the chat's id moved the stale first segment to the lane and the
owner-row branch of moved_receipts released the receipt guarding the
post-/new segment, which was still local: that transcript was reachable from
nowhere and the platform resumed the stale history. The handoff now repoints
the receipt's current head (latest /new segment, then its compression tip,
hermes_state_local.handoff_head); a receipt is released only when the row its
CURRENT target names moved to a messaging lane, and the rest of that
conversation moves with it in the same write (hand_off_conversations): its
earlier segments open as the lane's read-only history and its name follows
the head over a generated title. A Desktop still holding the chat follows it
(follow_hand_off).
- Bot Chat after the platform's /new. The roster's canonical_session (raw title
registry) still named the superseded handed-off chat while the title lookup
hid it, so Desktop's bot row failed closed ("Could not confirm ... not
starting a new chat") on every click. The title now moves aside at the moment
the lane moves off the chat (SessionStore._promote_session_reset, every route
transition, and at boot), so the registry, the roster and the lookup agree
at every step; the lookup-side filter and the create-side move-aside go.
- Second handoff to the same home chat. A live local chat handed off to a route
that already carried a handed-off chat skipped the supersede loop, leaving
the first chat writable while the route named the second. _publish_route now
supersedes every other handed-off/history mount on a route whenever a
session takes it over.
The PR's own history='reset' case asserted the old (stale-segment) behaviour;
it now asserts the head carries on and the earlier segment is read-only history.
Matching a receipt by its owner row joined receipts whose value is not JSON, and the route
comparison then raised `malformed JSON` out of every repoint and boot heal, where it used to skip
them (tests/gateway/test_local_recovery_pre_profile_origin.py). Only well-formed receipts are
compared; a malformed one stays as it is, refused and logged when touched.
Review of #144708 (Bot Mode keeps one canonical forever-chat per profile + title):
- A Bot/cron delivery after the handoff treated the handed-off Bot Chat as displaceable
(a messaging row with no receipt), renamed it aside and minted a new canonical chat, so
the bot row silently switched chats. `displaceable_bot_chat` keeps a handed-off chat whose
lane still holds it, and `_target` delivers to it on that lane.
- The Desktop view that followed the chat onto the lane stayed writable after the platform's
own /new, with a route naming the new conversation (Stop, agent lookup and turns would act
on it). A handed-off live session is now superseded like a history mount when its lane
publishes another conversation.
- After that /new and a restart the bot row adopted the read-only chat forever. The title
lookup no longer resolves a handed-off Bot Chat whose lane moved on, and Desktop's titled
create (and the delivery door) move it aside, `Bot Chat (<platform> <id>)`, before minting
a fresh one.
- The in-handoff receipt release applies the startup heal's messaging-lane filter.
Also: a chat with a /new reset segment handed off by its conversation id (a platform
`/resume <title>` or a resumed CLI) kept a receipt naming the reset segment while its owner
row moved to the lane, so every cold open failed the lineage check. A receipt whose owner
row a repoint moved is released too (in the write and by the startup heal).
`/handoff <platform>` repoints the platform route at the local session
(SessionStore.switch_session -> record_gateway_session_peer), which rewrites
the row's routing identity to the platform's. Its local receipt
(gateway.local_policy.v1 / gateway.local_legacy.v1) stayed behind, so the row
matched neither lane:
- authorize took restore_local_session (receipt present) and _checked_entry
refused it with `stored identity mismatch`: resume, pin/unpin, rename and
archive answered storage_unavailable, and every boot logged
`receipt refused`.
- the title registry (session.list title=, session.resume title=, the Bots
roster's Bot Chat lookup) sent the row to legacy adoption, which answers
not_found for a messaging row and for any non-native actor (browser
Webapp), so a handed-off canonical Bot Chat could never be opened again.
Now:
- the explicit repoint retires a local receipt whose target row it moved to
another route, in the same write (hermes_state_gateway ancestors mode);
- gateway boot heals stores an older build left that way: receipts whose
target row a messaging lane now holds are released (logged once);
- the title resolver returns a local conversation a messaging lane took over
(created by a local surface, mountable on its route) through the routed
mount, so `/resume <title>` and the Bot Chat registry open it there;
- the handoff publishes the lane on the authority, and _publish_route moves a
still-open LOCAL live entry onto it, so the Desktop that ran /handoff keeps
its view without a restart;
- resume refreshes the creation policy only for local routes (a platform
lane has none; it logged `keeps its stored policy` on every open).
Fixes#143286
main stamped desktop_local into a Desktop chat's frozen create request; d838333682 moved the mark to
the policy's own config. For a chat main saved, the Desktop's exact retry after a reconnect (which
never sends the key) was refused as invalid_params, and its turns no longer ran as work for the local
Desktop.
client_request drops desktop_local from the saved request, since the owner stamped it and no client
can send it (CREATE_FIELDS refuses it). local_desktop_turn reads the mark from either place.
Three comments said an auxiliary-usage 'unknown' created_source "never
moves". Since the creator upsert's NULLIF(created_source, 'unknown') in
this series that is false; only commit_local_session's ON CONFLICT DO
NOTHING insert leaves it, which is why that insert stamps the surface.
Say that instead.
Auxiliary usage inserts created_source=unknown when it creates the row,
and that value never moves. The agent's own token accounting passes its
surface and is not that writer. Stamp the agent surface in the insert
(gui is desktop) so a later usage write cannot fill the column. A peer
refresh that lands first still leaves worker_create refused, but latest
can see the row.
An exact session.create retry was judged by same_create_request and by the
client_env rules frozen with the session when it found the session
committed and at commit, but an overlapping alias retry that reuses the
key its slot holds was matched by same_create_request alone. client_env is
kept outside the frozen request, so a retry that differed from the slot's
holder only in client_env took the held key, and got a session with it
whenever the holder never committed.
same_create_retry is now the one exact-retry identity: what the client sent
(same_create_request) plus its client_env judged by the rules frozen with
the saved policy (retry_matches). Staging, the transactional commit and
bind_launch_key all use it; the key slot remembers its holder's request,
client_env and rules, and stage_local_session passes the retry's own
client_env (in memory only). Such a retry is now refused at staging, as a
retry with another key is; a name the holder's rules drop still reuses the
held key.
Refs #134889 (LC-8)
Refs #139523
An exact session.create retry was checked only against the names the
stored, filtered client_env kept, so a retry that added a name the rules
keep was accepted, and an identical overlapping create could be refused
at commit.
The rules in force when the session is committed (behaviour names, the
built-in toolchain names and configured extras, prefixes and secret
substrings) are now stored with its policy as plain data
(client_env_rules_json). A retry's complete client_env, filtered by those
rules, must equal the stored map, both at staging and in the
transactional commit: adding a name the rules keep, dropping a stored name
or changing a value is refused, and a name the rules drop is ignored. The
losing create's own map is carried only in memory on its staged receipt,
never written; a receipt that has lost it (a plain dict copy, for
example) is refused at commit (invalid_params) rather than judged by its
own filtered map. A session created without client_env still retries
only without one, and the policy identity leaves the new field out when
it is empty, so existing fingerprints don't change.
Refs #134889 (LC-8)
Refs #139523
On main each `hermes` process read HERMES_EPHEMERAL_SYSTEM_PROMPT,
HERMES_PREFILL_MESSAGES_FILE and HERMES_INFERENCE_PROVIDER from its own
shell. Since the one gateway runs every session, the values exported by
whichever client auto-started it became daemon-wide (messaging and cron
included), and every later client's exports were ignored.
The classic CLI and the Ink TUI now send an allowlisted `client_env` map
(plain strings) with session.create; the classic CLI takes the
allowlist's extra names from the profile's effective config (managed
layer included), as the gateway reads them, and a safe-mode or
ignore-user-config launch reads no profile. The gateway filters the map
again against the session profile's allowlist, freezes it with the
session's policy (outside launch_request, so viewers never see the
values) and binds it for that session's turns only: the ephemeral
prompt, the prefill file and the provider request read the session's
value, in-process and for a managed worker. Desktop sends nothing and
keeps the gateway's environment, as does a session created before this
change.
The auto-started daemon no longer inherits these variables (nor
HERMES_ENABLE_PROJECT_PLUGINS); a gateway the operator starts keeps its
own environment. Clients send the field only when runtime.describe lists
it.
An exact retry of a session created before this change returns its
original session; the retry and commit comparisons and the policy identity
treat an absent client_env as before. A retry is judged against the
committed, filtered map: every stored name must come back with its stored
value, and names dropped at creation don't count, whatever the allowlist
is now. Fresh creates use the current rules, and the unfiltered map is
never stored.
Refs #134889 (LC-8)
Refs #139523
Gateway-owned sessions (in-process turns and managed workers alike) got
the runner's prefill examples, loaded once when the daemon started from
the launch profile: a prefill_messages_file path or file edited after
that never reached a new session. On pre-merge main every `hermes chat`
process read the file once at startup from its own profile.
session_prefill() resolves the path from the session policy's
creation-time config, relative to the session's own profile home. The
file is read by the creation attempt's commit (commit_local_session),
before its write transaction; on the async create paths that runs in
tracked_write's worker thread, not on the owner's event loop. The
snapshot is carried with that creation attempt. It is held in the owning
authority's memory only when that attempt's commit creates the session.
An overlapping retry or a conflicting create never replaces it, and a
refused or failed create (for example a retry of a deleted session) holds
nothing. Other sessions cannot evict it, an edit after creation reaches
only new sessions, and deleting the session releases it. A session this
owner did not create (restored after a gateway restart, or a new branch)
reads the file once on first use and keeps it; main's CLI likewise
re-read it on every start. The in-process agent build and
construct_inputs for managed workers share the snapshot, so both send the
same examples. Bypass sessions still never load the profile's file. The
HERMES_PREFILL_MESSAGES_FILE env precedence is unchanged.
Related open PR, not used: #124630 (re-reads the active profile on every
agent build for multiplexed messaging turns, against the pre-split
layout; does not read the session's frozen config).
Refs #134889 (CFG-7)
Refs #139523
A single-profile owner binds no secret scope and never reloads .env for
local turns, so session.create expanded config.yaml ${VAR} refs (MCP
headers, provider base_url, ...) against the os.environ the daemon
started with; a rotated value only reached new sessions after a
restart. Tool env already refreshes from the profile .env.
stage_local_session now reads config through load_profile_config: it
binds the profile's secret scope (build_profile_secret_scope, the same
.env + secret-source + managed composition the owner scope uses) around
the read when none is bound. A served profile's owner scope is kept as
is; on the launch profile a scope miss still falls to the process env,
so systemd / op run injection keeps resolving. Frozen sessions are
untouched (their stored config is not re-expanded).
Refs #134889 (CFG-4)
Refs #139523
Main stamps sessions.cwd only from the agent's own lazy create
(run_agent._launch_cwd_for_session: the process cwd, CLI-family label, local terminal
backend). /new and --create-if-missing rows stay empty, and -w records the repo root
because main never chdirs into the worktree (it sets TERMINAL_CWD). The gateway wrote
the policy cwd (the execution dir) on every row, so a piped bare `-c` (workspace-scoped
MRU) picked a newer /new or titled session that main skips as cwd-less.
Split row metadata from runtime cwd rather than changing execution:
- session.create takes an optional `row_cwd` (canonical contract + CREATE_FIELDS; absolute
or "" = none). It is frozen in request_json; hermes_state_local._row_cwd writes it to
the row at the creation commit, else the execution cwd as before (every other surface
is unchanged). policy.cwd / TERMINAL_CWD are untouched: -w tools still run in the tree.
- GatewaySessionStore.create sends main's stamp: os.getcwd() for a CLI-family label on a
local backend, "" for /new (CLIInitMixin._new_session_id) and --create-if-missing
(create_titled_session, record_cwd=False) and for remote backends.
- TS/OpenRPC regenerated with gen_gateway_contracts.py.
Reset/compress/branch children keep copying policy['cwd'] (main's children inherit the
parent's row cwd via the compression/branch path; not exercised by the harness).
Red first: tests/gateway/test_session_row_cwd.py (row vs policy cwd, contract) and
test_gateway_session_store::test_row_cwd_is_mains_stamp... failed before the change.
(cherry picked from commit 532c257783f20ebe603a74b81e3551a7091feecf)
A canonical local reset keeps the creation id S0 as the logical owner
(policy receipt, FIFO, generation) and moves the transcript to a physical
child S1; a follower queued on S0 runs against S1. Four legacy paths treated
S1 as an independent row:
- delete_session(S1): owned_lineage_ids returned [S1] for a non-owner
segment, so the listed row (the latest segment) was deleted alone and
retire_local_receipts dropped S0's policy (its target was S1), while S0's
queued admission survived with nothing to run on (restore_local_session
-> storage_unavailable). The live-work check saw only S1's ledger.
- delete_sessions([S1]) without the chain expansion: same.
- prune_sessions / prune_empty_ghost_sessions (retire_prunable): the live
ledger was checked per physical id and _retained_local_owners protected an
owner only while its target was NOT swept, so an aged S1 was retired while
S0 still had queued work.
- prune_never_active_keyed_sessions: a fresh reset tip looks never-active.
Fix: owned_lineage_ids now returns the whole local conversation for any
segment, so delete_session / delete_sessions / get_session_delete_targets
delete (and live-check, refusing session_busy) the conversation as one, the
way the gateway delete already does (delete_targets). retire_prunable sweeps
a local conversation only when every id of it is in the sweep and none has
live work; otherwise it skips all of them. The never-active selector and
delete_session_if_empty leave lineage segments alone (a fresh reset tip is the
current target of a conversation with history). test_terminal_retirement D7
pinned the old lone-child delete; it now asserts the whole-conversation
delete strips the same receipt text.
Tests (red on r11-base: child/policy gone with the admission still queued, and
the idle current-segment delete left S0 behind):
tests/hermes_state/test_reset_lineage_delete_queued_work.py::test_segment_delete_or_sweep_keeps_a_conversation_with_queued_work
tests/hermes_state/test_reset_lineage_delete_queued_work.py::test_idle_conversation_deletes_whole_from_its_current_segment
Review: JoaoMarcos44 R1
Review: andrexibiza 8
Review: dokterdok corr-reset-delete
(cherry picked from commit a904f658f6a19eee7a371684e3050d6349eec5bc)
list_sessions_rich drops earlier local reset segments in SQL (one
conversation, one row: hermes_state_local.local_lineage_index), but
session_count and session_count_by_source applied only
_session_filter_where. The dashboard's "load more" total and its
total/active/archived/by-source stats therefore counted every reset
segment as another conversation and disagreed with the page they pair with.
The superseded-segment filter is lifted into
hermes_state_local.exclude_superseded_segments and applied by the listing
and by both counts whenever they mirror listing visibility
(exclude_children=True); the raw physical count is unchanged.
Test (red on r11-base: count 2 for one listed conversation):
tests/gateway/test_reset_lineage_session_count.py::test_reset_lineage_counts_as_one_conversation
Review: andrexibiza 26
(cherry picked from commit 2b91dad4c6474413d207fe570e2d89bd2b34e558)
Follow-up to the local reset lineage projection: the listed row no longer
rewrites _lineage_root_id to the creation id. Pins, archive and read flags are
compression-lineage columns on the listed segment's own chain, and the Desktop
keys pins on _lineage_root_id ?? id, so pointing it at the ended creation row
would pin a row the listing never shows. Every segment still rides in
_lineage_ids (delete/tombstone/stored-id matching), and a delete of the listed
row removes the whole lineage through delete_targets.
Review: https://github.com/NousResearch/hermes-agent/pull/106742#pullrequestreview-5475590057
A canonical local reset keeps the creation id S0 as the logical owner (policy,
FIFO, generation) and moves the transcript to a physical child S1. The paths
disagreed about that:
- list_sessions_rich (Desktop/dashboard /api/sessions, TUI picker) showed S0
and S1 as two conversations;
- deleting the current S1 through the gateway was refused storage_unavailable
(restore_local_session(S1) finds no receipt), while deleting the old S0
silently took the current conversation too;
- `hermes sessions delete <S0>` / dashboard bulk delete removed only S0 and
left S1 readable but never continuable, restorable or deletable.
Project the lineage as one conversation everywhere, like
acp_adapter/catalog.py:_local_owners:
- listing: earlier reset segments are filtered in SQL before LIMIT/OFFSET (no
short or duplicate pages); the representative row carries the creation id as
_lineage_root_id and every segment in _lineage_ids, which the Desktop already
uses for delete/pin/tombstone matching;
- gateway delete of any segment authorizes and gates on the owner and removes
owner + every segment (delete_targets resolves the owner first);
- legacy delete of the creation id takes its segments with it (its policy is
retired); bulk/chain delete expands to the whole local conversation. Deleting
only a physical child through the legacy single-row path keeps its historic
contract (test_terminal_retirement D7).
Keeps test_delete_after_reset_covers_physical_route_and_cache_generation.
Review: https://github.com/NousResearch/hermes-agent/pull/106742#pullrequestreview-5475590057
`status!='terminal'` cannot bound the session_admissions_pending (target, status, seq) or
idx_worker_executions_session_status (session, status) index, so every pending listing, idle
check (require_idle, require_not_executing, end_idle_local_session) and prune live-ledger probe
walked a session's whole finished ledger. That ledger only grows, and these probes run inside
the writer transaction on every turn / delete / idle end. Measured at 200k terminal rows: 6.35
ms per probe before, ~0 after. The predicates are now an explicit IN over the live
statuses of each table's CHECK domain ('queued','started','unknown' /
'registered','running','unknown'), same rows and an index range seek. The regression counts
SQLite VM steps against 40k finished rows: about 120k steps on r6-base, under 20k here.
Same predicate in gateway/session_worker_reservation.py and gateway/platforms/webhook_ingress.py
is in other lanes' files (NOT_COVERED.md).
Review: https://github.com/NousResearch/hermes-agent/pull/106742#pullrequestreview-5462648005 (section 3: pending/claim checks)
The deletion fence scans every gateway_routing entry (retire_routes) and every
gateway.local_policy.v1:* creation receipt (retire_local_receipts) to find the ones that name
the sessions being deleted, and called json.loads(...).get(...) on each. One unrelated row that
is a JSON array, an object whose `entry` is not an object, or invalid JSON raised
AttributeError/JSONDecodeError inside the writer transaction, so canonical delete, legacy
delete_session(s) and prune all rolled back for a session that has nothing to do with that row.
The base deletes it, and the production routing loader already skips non-object rows.
A malformed row names no target: it is neither retired nor allowed to abort the sweep, and is
left untouched (its own readers keep refusing it as storage_unavailable). Regression covers the
four malformed shapes x canonical/legacy/prune; 12/12 fail on r6-base, all pass here.
Review: https://github.com/NousResearch/hermes-agent/pull/106742#pullrequestreview-5460639040 (W15)
Review: https://github.com/NousResearch/hermes-agent/pull/106742#pullrequestreview-5458083694 (Session deletion availability)
The deletion fence scrubbed admission payloads and worker results but left
gateway.local_policy.v1:<id> (whose request_json holds a cron fire's job
and raw prompt, gateway/session_cron.py) and gateway.api.binding.v1 /
gateway.api.conversation.v1 receipts in state_meta forever. Every boot's
recover_local_sessions then walked the orphan policy and logged
"Local session ... paused: storage_unavailable", and a declared API
conversation key kept resolving to the deleted id.
retire_sessions (every delete/prune/sweep path) now calls
hermes_state_local.retire_local_receipts: a policy goes when its current
physical target is deleted (a pruned reset ancestor whose session lives on
keeps it); an API binding and any declared key pointing at it go with the
id. The API prefixes move beside POLICY_PREFIX so the state layer owns them.
commit_local_session and bind_api_session refuse a retired id with
not_found, so a create/cron/API retry cannot resurrect a deleted session
under its deterministic id.
Review: https://github.com/NousResearch/hermes-agent/pull/106742 (@itsflownium, Persistence: delete fence leaves policy/binding receipts)
Restores main 9fb2dd4c39 (#118216) on the gateway path. ACP v0.9 has no
per-session destroy; the in-process adapter stamped ended_at when its stdio
transport closed. Under the gateway the ACP process is a viewer and nothing
wrote the stamp (the entry.py comment claimed the daemon's reset lifecycle
did), so source='acp' rows stayed open forever and prune/archive's
ended-session guard never selected them.
- SessionAuthority.detach: when the last subscriber leaves, an ACP-created
local session (policy source 'acp') is ended 'acp_disconnect'.
- settle_gateway_runtime: at stop, every ACP session nobody is viewing.
- hermes_state_local.end_idle_local_session: idle check (no non-terminal
admission/worker) and the stamp in ONE epoch-fenced owner write txn, so
queued work keeps the row open. The drain's reopen_local_session already
reopens an ended row on the next admitted turn (session/load + prompt).
(cherry picked from commit 84787b2245e85ac0d5f5703f4be559af976172cf)