Skip to content

fix(compression): keep lean tail budget through runtime recalibration - #93576

Closed
TurgutKural wants to merge 1 commit into
NousResearch:mainfrom
TurgutKural:fix/lean-tail-budget-recalibration-v2
Closed

TurgutKural wants to merge 1 commit into
NousResearch:mainfrom
TurgutKural:fix/lean-tail-budget-recalibration-v2

Conversation

@TurgutKural

@TurgutKural TurgutKural commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Problem

The tail_token_budget property (agent/context_compressor.py) is mode-aware: in lean mode it derives a clamped 2.5%-of-window budget; in legacy mode it computes threshold_tokens * summary_target_ratio. The result is cached in _tail_token_budget.

The aux compression-model threshold-sync path in agent/conversation_compression.py::_lower_threshold_to_aux_context wrote the LEGACY formula directly into the cached field, unconditionally. After it runs while tail_mode is "lean", the cached budget permanently reflects the legacy formula — the tail's 1.5x soft ceiling widens past the lowered trigger, compression preserves nearly the entire request, and compaction re-fires repeatedly. The session keeps claiming tail_mode: lean while behaving legacy.

Note: this PR deliberately does NOT touch update_model(). Current upstream main already invalidates the cache there (self._tail_token_budget = None, mode-aware recompute), verified by test_update_model_preserves_lean_mode on main. Related #92738 (still open) proposes a lean/legacy branch inside update_model() — that outcome is already what main produces unconditionally, so #92738 is redundant for update_model(); this PR addresses the remaining live writer, the aux-sync path.

Fix

Add a policy-neutral ContextCompressor.recalibrate_tail_budget() hook whose only responsibility is invalidating _tail_token_budget. _lower_threshold_to_aux_context() calls it unconditionally after lowering the threshold and lets the existing tail_token_budget property remain the single owner of lean-vs-legacy policy:

  • legacy re-derives threshold * ratio (behavior unchanged),
  • lean re-derives its context-window clamp,
  • no cross-module private cache mutation, no duplicated tail_mode branching,
  • future tail policies stay localized to ContextCompressor.

Testing

Live repro on current main before fix (real compressor, 1M window, aux 80K): lean 25K -> 16K (bug), legacy 130K -> 16K (correct). After fix: lean 25K -> 25K, legacy unchanged.

pytest tests/run_agent/test_compression_feasibility.py  # 12 passed (10 existing + 2 new)
pytest tests/agent/test_context_compressor.py           # 152 passed (unchanged behavior)

New regression tests use a REAL compressor end to end through _check_compression_model_feasibility(): test_aux_sync_keeps_lean_tail_policy (lean survives the sync) and test_aux_sync_legacy_tail_follows_lowered_threshold (legacy follows 80K * ratio). The pre-existing MagicMock test now asserts the sync goes through the hook (recalibrate_tail_budget.assert_called_once()), with a mock side-effect mimicking the real legacy re-derivation.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/compression Context compression and continuation sessions sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 24, 2026
@alt-glitch

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Related: #92738 already repairs the update_model() half of this lean-tail cache invalidation bug. This PR also covers the auxiliary compression-model threshold-sync path, so the proposals overlap but have different scope.

@TurgutKural
TurgutKural force-pushed the fix/lean-tail-budget-recalibration-v2 branch 8 times, most recently from 857091b to 4639c95 Compare September 1, 2026 04:03
@TurgutKural
TurgutKural force-pushed the fix/lean-tail-budget-recalibration-v2 branch from 4639c95 to 764326c Compare September 2, 2026 04:05
@TurgutKural

Copy link
Copy Markdown
Contributor Author

Scope note for reviewers: #92738 (still open) covers the update_model() half of this lean-tail invalidation bug. This PR covers both halves — update_model() and the aux compression-model threshold-sync path in check_compression_model_feasibility() — via a single recalibrate_tail_budget() helper so the mode-aware tail_token_budget property stays the single source of truth for both modes. If #92738 lands first, this PR narrows to the aux-sync half plus the shared helper.

… sync

_lower_threshold_to_aux_context wrote threshold*ratio directly into the
cached tail budget, silently reverting lean mode to legacy size while
tail_mode still claimed lean. Route it through the new
ContextCompressor.recalibrate_tail_budget() hook so the mode-aware
tail_token_budget property stays the single source of truth.

update_model() already invalidates the same way on current main;
deliberately untouched.
@TurgutKural
TurgutKural force-pushed the fix/lean-tail-budget-recalibration-v2 branch from 1f37d3b to f442623 Compare September 4, 2026 16:35
teknium1 added a commit that referenced this pull request Sep 8, 2026
Lowering the session trigger must not replace the window-relative lean
selection budget with threshold times target_ratio. Invalidate the lean
cache through the existing property while preserving explicit legacy and
external-engine fallback behavior.

Narrow adaptation of the aux-sync diagnosis and invariants in #93576,
without adding a required recalibration method to context engines.
Related: #95681, #93576

Co-authored-by: Turgut Kural <58116817+TurgutKural@users.noreply.github.com>
teknium1 added a commit that referenced this pull request Sep 8, 2026
Lowering the session trigger must not replace the window-relative lean
selection budget with threshold times target_ratio. Invalidate the lean
cache through the existing property while preserving explicit legacy and
external-engine fallback behavior.

Narrow adaptation of the aux-sync diagnosis and invariants in #93576,
without adding a required recalibration method to context engines.
Related: #95681, #93576

Co-authored-by: Turgut Kural <58116817+TurgutKural@users.noreply.github.com>
@teknium1

teknium1 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Thanks @TurgutKural for identifying the auxiliary threshold-sync writer and tracing the lean-tail policy mismatch. This remaining scope is now fixed on main by #105935, commit 19cd839: auxiliary recalibration invalidates the lean cache while preserving legacy threshold-derived behavior. Live verification kept the 1M-window lean tail at 25K instead of inflating it to 102400 with a 512K auxiliary window; legacy behavior was also checked. Closing as resolved by the landed implementation, without adding the extra recalibration wrapper.

@teknium1 teknium1 closed this Sep 8, 2026
mrkillbob added a commit to mrkillbob/hermes-agent that referenced this pull request Sep 10, 2026
* fix: keep Bot Mode pet selection rings inside the gallery

* fix(prompt): keep memory guidance within available tools

* fix(compression): keep lean tails lean after auxiliary feasibility

Lowering the session trigger must not replace the window-relative lean
selection budget with threshold times target_ratio. Invalidate the lean
cache through the existing property while preserving explicit legacy and
external-engine fallback behavior.

Narrow adaptation of the aux-sync diagnosis and invariants in #93576,
without adding a required recalibration method to context engines.
Related: #95681, #93576

Co-authored-by: Turgut Kural <58116817+TurgutKural@users.noreply.github.com>

* feat(desktop): let users order Group Chat rooms

Add Move up/down controls for actual rooms without changing bot or folder
ordering. Preserve default pin/activity ordering until an explicit move,
retain hidden room slots, and persist Desktop-local order through room
updates, mirror merges, and hydration. No membership or routing writes.

Adapted narrowly from the group ordering idea in archived
NousResearch/Hermes-Bot-Mode#105 by @onuraycicek; rename already exists.

Co-authored-by: Onur Aycicek <onur.m.aycicek@gmail.com>

* fix: hide inactive grouping options from delegation schema

* fix(desktop): show the focused bot's working think pose

Port Adolanium's focused-turn pose from Hermes-Bot-Mode#101 and
hermes-agent#88134 to the current typed Bot Mode implementation.
Match the busy signal's connection-qualified focused owner rather than
the gateway socket, retain worker activity, and ease transitions in
elapsed time on the existing shared face clock.

Includes owner-isolation and animated-pose invariants, both proven red
on origin/main, and native Electron before/after verification against
a real temporary Hermes backend with held loopback inference.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>

* fix(desktop): load property card guidance only on demand

* fix(cli): keep monitor repaints safe during prompt handoff

* fmt(js): `npm run fix` on merge (#106039)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fmt(js): `npm run fix` on merge (#106065)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* test(desktop): retire model catalog fixture jobs at teardown

* fix(desktop): let subagent header collapse roster and details

* feat: add GPT Image 2.5 generation and editing to OpenAI provider

* feat: add FAL GPT Image 2.5 generation and editing selections

* chore(deps): bump httpx2 in the uv group across 1 directory

Bumps the uv group with 1 update in the / directory: [httpx2](https://github.com/pydantic/httpx2).


Updates `httpx2` from 2.7.0 to 2.12.0
- [Release notes](https://github.com/pydantic/httpx2/releases)
- [Changelog](https://github.com/pydantic/httpx2/blob/main/src/httpx2/CHANGELOG.md)
- [Commits](https://github.com/pydantic/httpx2/compare/v2.7.0...v2.12.0)

---
updated-dependencies:
- dependency-name: httpx2
  dependency-version: 2.12.0
  dependency-type: direct:production
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>

* feat(desktop): default glass to 29% tint on the sidebar

* docs(desktop): document sidebar glass defaults

* fix(desktop): keep background reports behind bounded disclosures

* docs(desktop): explain background report disclosures

* fix: trim computer use tool schema guidance

* fix(desktop): pin Windows update handoff cwd

* test(desktop): exercise production cwd setup in Windows self-test

* fix(mcp-oauth): keep refresh_token when a refresh response omits it (#62333)

HermesProviderMixin._handle_refresh_response overrides the SDK's handler (to
accept any 2xx and keep token bodies out of logs) but dropped the SDK's RFC 6749
section 6 carry-forward. An authorization server that does not rotate refresh
tokens (TinyFish, Google, Zoho, Asana, Futu) answers the refresh grant without a
refresh_token; we then stored the response verbatim, erasing the only refresh
token we had, so the next expiry had nothing to refresh with and forced a
browser re-auth roughly one TTL after every login.

Carry the prior refresh_token (and scope, per section 5.1) forward on the
OAuthToken before _store_tokens, so both the live provider and the on-disk
token file keep it. A rotating AS still wins: only None fields are filled.

Tests: two invariants on the real HermesMCPOAuthProvider + HermesTokenStorage
(omitted -> preserved in memory and on disk; provided -> rotated). The
carry-forward test is red on main.

* fix(desktop): allow project creation while browsing all profiles

* test(desktop): cover project creation scope and reconnect routing

* fix(observability): attribute ACP and batch execution surfaces

Fleet telemetry showed "unknown" as the single largest execution_surface
bucket. Two construction paths were mis-attributed, both silently:

1. ACP editor sessions (VS Code / Zed / JetBrains) declare platform="acp",
   but "acp" was absent from EXECUTION_SURFACES, so the contract's
   closed-schema fallback folded every editor session into "other" --
   the bucket meant for genuinely unclassifiable traffic.

2. batch_runner built agents from _AGENT_PASSTHROUGH, which omitted
   "platform" entirely, so every batch task run reported "unknown"
   despite "batch" already being a first-class surface.

Neither is a reporting bug in the exporter: both are declaration gaps at
the construction site. "unknown" must mean "this run genuinely could not
be attributed", not "a construction site forgot to say who it was".

Changes:
- add "acp" to EXECUTION_SURFACES and map it to the "interactive"
  entrypoint alongside cli/desktop/tui
- add "acp" to the v2 wire schema enum (kept in sync by an existing test)
- pass platform through batch_runner: added to _AGENT_PASSTHROUGH, set
  self.platform = "batch" on the runner, and defaulted at the worker call
  site so callers that build a config without it stay attributable

Wire compatibility: the ingest service validates the envelope only and
stores metric bodies verbatim, so packages carrying the new value are
accepted by the already-deployed server. No coordinated deploy needed.

Tests: 12 new behavioural tests. Verified red before the fix (4 failed),
green after. Three fix-mutants confirmed killed:
  M1 revert acp from EXECUTION_SURFACES  -> 3 failed
  M2 revert acp entrypoint mapping only  -> 1 failed
  M3 revert batch passthrough            -> 1 failed
No source-text assertions; every test is a contract between the surfaces
the schema accepts and the surface each path declares. A guard test pins
that a genuinely undeclared run still reports "unknown", so attribution
cannot be "fixed" by inventing a default that hides real gaps.

* fix(desktop): keep visible renderer animations running on blur

* fix(desktop): keep pets and starmap animated without focus

* fix(desktop): keep macOS HUD visible when inactive (#102573)

* fmt(js): `npm run fix` on merge (#106231)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* feat(desktop): show messages below the thread viewport

* test(desktop): cover scrolling message counts and pane isolation

* fmt(js): `npm run fix` on merge (#106237)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(agent): a surface switch must not re-prefill the whole request (#104414)

`_stored_prompt_matches_runtime` treated `Platform` as a runtime-identity field, so
answering a live session from another surface — desktop -> TUI, or a resume after a
dashboard restart whose chat is a PTY TUI child — declared the stored prompt stale and
rebuilt it. The system prompt is the first thing in the request, so changing any byte of
it moves the first divergent byte to the head of a 220K-token request and the entire
conversation behind it re-prefills: a session that was hitting 240000/240287 came back
at 1536/219861.

The guard was not wrong about correctness — a desktop-built prompt on a terminal session
advertises inline widgets and a MEDIA: channel the TUI does not have — but the surface is
advisory metadata about the renderer, not a cache domain. Model/provider and cwd drift
change what the prompt should SAY; the surface changes only one paragraph.

Reuse the stored bytes across a surface switch and correct the paragraph where it costs
nothing to cache: `_stage_surface_switch_note` stages a one-shot note carrying the CURRENT
surface's guidance on the same per-turn user-message channel the gateway's must-deliver
notes use. It lands after the cached prefix and is stamped into the byte-stable
`api_content` sidecar, so later turns replay it instead of re-prefilling, and the prompt
converges at the next compaction — a boundary that already breaks the cache.

The saved tool_names prefix is not pinned across a switch: the tool registry is
process-global, so `_merge_preserving_prefix` would carry a saved-but-unloaded tool
forward (under `coding_context: focus` desktop gets a desktop_ui toolset the TUI cannot
run). On the same surface the tools freeze is untouched.

* fix(agent): skip the tools freeze once on a surface switch, not for the session

The first cut gated the saved tool_names pin on "the surface drifted", which stays true
for as long as the stored prompt names the old surface — i.e. until the next compaction.
On the gateway path, where a fresh AIAgent is built per turn, that left the tools freeze
off for every remaining turn, so a check_fn that flaps could reorder `tools[]` and break
the tool cache block on its own.

Gate it on the turn that actually ANNOUNCES the switch instead, and persist the fresh,
toolset-correct names there. The next turn's row already holds this surface's tools, so
the pin resumes immediately: skipped once, not disabled.

* fix(agent): the surface note must not outlive its own truth

Two holes the first cut left open, both created by the note itself.

Switching BACK to the surface the prompt was built for (desktop -> tui -> desktop) left the
`Platform:` trailer agreeing with the runtime, so nothing was staged — while the newest note
in the transcript still told the model it was on tui. And a rebuild for an unrelated reason
(a model switch) refreshed the prompt but not that note, leaving the same contradiction from
the other side.

Compare the runtime surface against what the model was last TOLD — the newest surface note
when one exists, else the prompt's own trailer — and stage from the rebuild path too. The
full surface guidance rides along only when the prompt itself is out of date; when the prompt
already describes the current surface the note just retires the stale one and points at it.

* fix(agent): hold the tools pin through a surface switch, name what it carried

The announcing turn used to skip the tools freeze and re-persist the array the new
surface had just built. That is the one mutation this fix cannot afford: tools[] is
serialized ahead of the system prompt, so rebuilding it moves the request at token 0
and re-prefills everything behind it — the exact cost #104414 measured (1% cache hit
on a 220K session), spent on the very turn the fix exists to make cheap. On a
`desktop -> tui` switch with a configured toolset selection (`_gui_surface_toolsets`
gives desktop `desktop_ui`, the TUI nothing), skipping the pin dropped ~a dozen tools
and bought back the whole miss.

The pin now holds. `_merge_preserving_prefix` still appends what the new surface
brought, so a `tui -> desktop` switch pays a break no freeze could have avoided, and
the tools it carries FORWARD are named at the end of the surface note instead of being
silently advertised: a `focus_pane` a terminal turn can only answer with
`tool_error("desktop only")` now reads as unavailable rather than as live capability.
The toolset converges at the next real rebuild boundary, where the break is already
paid.

Credit to @StanleyStetson, who caught that the tool array is evaluated ahead of the
system prompt and that the bypass reintroduced the miss this PR is about.

* fix(agent): retire stale surface notes on bot-chat refresh, isolate platform from decoys

When Bot Chat capability refresh rebuilds the system prompt for the current
surface, call _stage_surface_switch_note() so any earlier switch note sitting in
the transcript is retired instead of overriding the rebuilt prompt.

Also isolate _stored_prompt_platform() to parse only the authoritative identity
portion before '# Hermes runtime environment' (with legacy fallback for prompts
without the boundary), preventing embedder prose or HERMES_ENVIRONMENT_HINT decoys
from shadowing the real platform and falsely suppressing surface switch announcements.

Credit to @ehz0ah, who identified both correctness gaps on current main and
verified the regression scenarios.

* refactor(agent): surface-switch note lives in its own sibling; skip it where no sidecar exists

Move the six surface-switch helpers out of the conversation_loop facade
into agent/surface_switch.py (AGENTS.md: new behaviour goes in a topical
sibling), and fold the review findings on #104494:

- MoA and codex_app_server turns never stamp the api_content sidecar, so
  the staged note could not be read back from the transcript and was
  re-sent on every turn after a switch. Those modes now skip the note
  (stored prompt still reused).
- The announced surface was parsed with split(".") — a plugin platform
  with a dot in its name would never compare equal and re-stage the note
  every turn. The note now closes the name with a fixed terminator.
- One identity-line parser (identity_line_value) shared by
  _stored_prompt_matches_runtime and the switch detector instead of two
  copies of the runtime-boundary/rpartition logic; tool names via the
  existing tools.mcp_tool_agent._def_name; the transcript scan is bounded
  to the last 200 rows (it ran every turn over the whole history).
- consume_surface_switch_note reduced to a plain pop; developer-guide
  prompt-assembly.md updated (Platform is no longer an identity field);
  17 new tests trimmed to 10 (same-shape pin/retire variants folded).

Restoring Platform as an identity field still turns 5 tests red.

* simplify(agent): surface switch — reuse flatten_message_text / agent_tool_names / one runtime-boundary split

- _transcript_row_texts re-implemented agent.message_content.flatten_message_text
  and the api_content sidecar rule; the note can only land on a user row,
  so the transcript scan now skips assistant/tool rows (the bulk of the bytes).
- Three sites computed "names of agent.tools"; tools.mcp_tool_agent gains
  agent_tool_names() used by the switch note and conversation_loop, which
  also stops importing the private _def_name across modules. The name list
  is only captured when a switch was announced.
- split_runtime_boundary() is the single owner of the runtime-block
  rpartition/END check for both identity_line_value and
  _stored_prompt_matches_runtime.
- platform_surface_hint was a public alias of _platform_hint; the function is
  now platform_hint (its docstring pointed at the pre-move module).
- consume_gateway_turn_context_notes and consume_surface_switch_note share
  _pop_turn_note so the two one-shot channels have identical semantics.
- platform check hoisted above the transcript scan.

* fix(agent): row-addressed api_content backfill for pre-persisted user turns (#102194)

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.

* fix(agent): read the sidecar row id under the session persist lock

_stamp_api_content_sidecar read _row_id without holding
_session_persist_lock. A close/early flush holds that lock while it
commits the row and only afterwards writes _row_id back onto the live
dict; a stamp that ran in between saw no id and skipped the backfill,
the flush finished with api_content = NULL and marked the message
persisted, and the turn-start persist skipped it — the row kept the
wrong bytes with no writer left to fix it.

Run the _row_id read and the DB backfill under the (re-entrant) lock,
re-checking _row_id after acquiring it. Race reported by @ehz0ah on

Co-authored-by: sal <141555468+salch-cred@users.noreply.github.com>
#102411; same fix shape as @salch-cred's follow-up on #103721.

* refactor(agent): one durable-row rule for the flush and the sidecar stamp; trim tests

The turn-start stamp had grown its own copy of the "what does the current
user row hold" rule (persist override = clean transcript, live content =
wire bytes = sidecar when they differ) that _db_flush_row already
implements. Two copies drift; extract durable_user_row_content() in
session_persistence and call it from both.

Also: reuse _persist_lock() instead of a third open-coded lock/nullcontext
ladder; drop the hasattr guard on set_latest_user_api_content (it predates
this fix and exists on every SessionDB); cut the comment to the WHY;
trim the new test file from 18 cases to the 7 invariants (real close
flush E2E, repeated-"ok" positional protection, API-only pre-flushed
turn, normal path writes nothing, compaction keeps positional, store
guards). Still 3 red / 4 green when agent/turn_context.py is swapped
for main's copy.

* simplify(agent): sidecar backfill — drop the hasattr guard and the duplicated row-id predicate; tests 7→6

_session_db is always a SessionDB (agent_init / delegate_tool), so the
"fail closed on a store wrapper" hasattr was defense around code that
cannot fail; the store's own guard binds the value into SQL, so the
prologue only needs the sibling idiom isinstance(_row_id, int) that
session_persistence and transcript_repair already use. The positional
hazard is explained once, on set_latest_user_api_content. The in-place
compaction test duplicated test_api_content_sidecar's
test_inplace_compaction_backfills_sidecar_into_db verbatim (its row_id
parameter was never varied); dropped, as was the positional-helper tail
of test_older_identical_row_is_untouched already covered there.

* chore: map contributor email for @0xalydev (#103581 salvage)

* fix(agent): inherit parent's full tool surface on review fork for cache parity (#103579)

Ensure unrouted background_review forks inherit the parent's full advertised
tools[] surface. Without this, skip_memory=True caused memory-provider tools
(e.g. fact_store/fact_feedback) and dynamically injected plugin/late MCP tools
to be omitted from the fork's tools array, breaking byte-exact prefix-cache parity
and incurring full cold-read costs on providers where tools are part of the cache key.
Inheriting the full parent tools array preserves complete prefix cache parity
while execution dispatch remains strictly bounded by the thread tool whitelist.

* fix(background-review): freeze review fork tool snapshot generation against compaction refresh

Freezes review_agent._tool_snapshot_generation to _FROZEN_TOOL_SNAPSHOT_GENERATION
(2_147_483_647) when inheriting the parent tool surface for same-model cache parity.

When in-place compaction boundaries trigger refresh_agent_mcp_tools(content_aware=True),
the staleness guard in _publish_tool_snapshot refuses the rebuild (snapshot_generation < published_gen),
preventing agent.tools from being reconstructed from the raw registry and preserving
inherited memory-provider and late tools across compaction boundaries (#103579).

Adds unit regression test verifying tool preservation across content_aware refresh.

Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>

* fix(background-review): inherit and freeze empty parent tools list for cache parity (#103579)

Copy and freeze review_agent._tool_snapshot_generation even when parent.tools is an empty list ([]). Previously, the truthiness check (\
ot parent_tools\) caused an empty parent tool surface to be skipped, allowing newly available late MCP or plugin tools to be retained on the review fork and leaving its snapshot generation unfrozen. This broke the byte-parity contract when no tools were active on the parent.

Returning early only when parent_tools is not an instance of list or tuple guarantees that an empty tool snapshot is faithfully inherited and frozen. Adds dedicated regression test test_unrouted_review_fork_inherits_empty_tool_surface.

* refactor(background-review): collapse the tool-surface copy to the agent_init shape; 2 tests

agent.tools is always a list (agent_init assigns it from
get_tool_definitions) and every entry is a well-formed function schema,
so the isinstance ladder over parent/entry/function/name guarded shapes
that cannot reach this helper. Use the same two lines agent_init uses;
`or []` keeps the empty-surface contract from the previous commit.
Docstring cut to the WHY (the between-turn refresh note described the
other guard). Tests trimmed to the two invariants: inherited tools
survive the compaction-boundary refresh (deep-copy isolation folded in),
and an empty parent surface is copied and frozen. Literal sentinel
asserts replaced with the constant.

* test(background-review): assert the behaviour, not the sentinel

The compaction-refresh and empty-surface tests pinned
_tool_snapshot_generation == _FROZEN_TOOL_SNAPSHOT_GENERATION next to
the behavioural assertion (refresh returns set(), tools unchanged). The
behaviour is the contract; the constant is the mechanism.

* chore(contributors): map sgarrand@gmail.com -> sgarrand

Scott Garrand (@sgarrand) identified the NixOS /bin/true systemd-probe bug
first in #102587; the salvage of #105436 credits him with a Co-authored-by
trailer, so the release script needs his mapping.

* fix(process-registry): use portable /bin/sh probe for systemd-run scope availability (#105365)

* test(process-registry): mark systemd probe tests linux_only

* test(process-registry): exercise the portable probe payload

Execute the selected no-op rather than freeze its spelling, while rejecting
/bin/true to model the NixOS failure. Mark the regression Linux-only and
retain the current user-bus environment handling.

Consolidates the earlier NixOS scope-probe report and fix in #102587 with
the PATH-independent payload from #105436. The fallback resolver is not
needed when /bin/sh is used directly.

Co-authored-by: Scott Garrand <sgarrand@gmail.com>

* fix(gateway): guard display config reads against present-but-null values

A profile config with a bare `display:` key (present-but-null) made
`user_config.get("display", {})` return None — the {} default only
applies when the key is missing — so the chained
`.get("memory_notifications")` in _wire_turn_agent_callbacks raised
AttributeError on every real gateway turn (Discord / cron). Oneshot
turns bypass this wiring, which masked the crash during smoke tests.

Use the same `or {}` guard the other gateway display readers
(display_config.py, runtime_footer.py) already apply, and fall back to
the documented default "on".

Fixes #105674

* test(gateway): fold the null/missing display cases into one parametrized test

* chore: map philmossman's contributor email (#105704 salvage)

* fix(cron): don't stamp the next occurrence on an off-tick manual run

claim_job_for_fire() derives the occurrence identity from next_run_at
before the same function advances it. On a scheduler tick next_run_at is
the occurrence being run, which is correct; on an off-tick manual run it
is the NEXT occurrence, so the execution is stamped with the identity of
a slot that has not happened yet. _job_is_due() then finds a completed
execution carrying that identity and skips the real slot, returning
before the last_dispatch write — no error, no log line, no dispatch
record.

The manual flag already guards this and both _job_is_due() and
claim_job_for_fire() honour it; the agent-facing run-now path never
declared itself. Add a keyword-only manual= parameter and pass it from
_claim_for_manual_run(). Deliberately not force=True: force also calls
_activate_job_record(), which would resume a paused or disabled job, and
the run-now tool depends on continuing to refuse those.

The local flag is renamed to manual_fire so the new parameter is not
shadowed inside the apply closure, which would raise UnboundLocalError.

Three existing tests in tests/tools/ pinned the old call signature via
assert_called_once_with; they now pin manual=True, so dropping the flag
again fails loudly rather than silently reintroducing the skip.

Restores the intent stated in #104790 — the column records the scheduled
instant an execution was claimed for, and an off-tick manual run was
claimed for none.

Fixes #105690

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cron): the dashboard "Trigger" run-now no longer stamps the next occurrence either

Second entry of the same bug class: POST /api/cron/jobs/{id}/trigger →
_fire_cron_job_for_profile → CronScheduler.fire_due → claim_fire built its claim
without `manual`, so an off-tick run from the web UI stamped the future slot exactly
like the tools path #105704 fixes. fire_due/claim_fire gain `manual` (forwarded only
when set, mirroring `force`, so third-party providers keep working) and the dashboard
trigger passes it when the provider's signature accepts it. Webhook and misfire
catch-up fires run the slot that is due and keep the stamp.

Also drops the base-green tick-stamp test (the same contract is pinned by
tests/cron/test_scheduled_occurrence.py) and documents `manual` vs `force`.

* chore: map tkaufmann's contributor email (#105463 salvage)

* fix(agent): classify local-inference memory-ceiling rejections as overloaded

oMLX/MLX prefill memory-guard rejections name an allocation peak in BYTES but
close with "Reduce context length", so _CONTEXT_OVERFLOW_PATTERNS claims them
and the turn enters the compress-and-shrink loop. Compression cannot lower a
prefill peak — the prompt is usually far below the window — so it burns the
compression budget, re-hits the wedged server on every attempt and ends in
"Cannot compress further" plus a destructive session reset.

Classify them as `overloaded` instead: retry with backoff, no compression, no
session reset (mirrors 503/529 recovery).

The guard runs before the overflow check AND before the usage-limit
disambiguation. The second ordering matters more than it looks: "memory limit
exceeded" contains "limit exceeded", so a status-less rejection — a proxy that
flattened the body — is currently classified as `billing` and rotates a
healthy credential.

Sites covered:
  - _OVERFLOW_AS_5XX_RULES, which _400_TAIL_RULES extends → 400, 500, 502,
    503, 529
  - _MESSAGE_HEAD_RULES for the status-less path (ahead of usage-limit)
  - _ERROR_CODE_VERDICTS for the structured oMLX codes
  - _classify_400, because _by_status runs before _by_error_code, so a body
    whose wording a proxy stripped would otherwise fall through to
    format_error

Every pattern names memory/allocation in bytes, never a token or window count,
so the list stays disjoint from _CONTEXT_OVERFLOW_PATTERNS. Both oMLX wordings
are kept: 0.5.6 says "Prefill would require ~13.87 GB peak", 0.5.7 reworded it
to "predicted peak would require/exceed" and both are in the field. A test
pins that a genuine window overflow still compresses.

Refs #52261. Supersedes #52289, which predates the classifier rewrite and can
no longer be merged.

* test(agent): fold the five memory-ceiling cases into one parametrized invariant

Same coverage (5 red on main, 1 guard green), one contract test instead of five.

* fix(agent): isolate periodic scheduler callbacks from blocking siblings (#102574)

* refactor(agent): one _requeue for the three heappush sites; timing test asserts ordering, not a 0.3 s bound

Fold the identical heappush(...) into PeriodicScheduler._requeue; notify() instead of
notify_all() now that the scheduler thread is the only condition waiter; drop the
PR-history paragraph from the module docstring (the commit carries it).

Tests: the blocked-sibling test asserted `sibling_ran.wait(0.30)` — a wall-clock bound
under the repo's ≥2 s flake floor; it now asserts the sibling fired while the blocker
still held its worker. The worker-start-failure fake keys on this scheduler's own
_run_callback rather than the global thread-name prefix so a leaked handle on _DEFAULT
cannot consume the single injected failure. The base-green no-overlap test is dropped
(it does not prove the fix).

* fix(agent): scope background review memory access to its trigger (#105921)

The review fork's tool whitelist granted the whole memory toolset
whenever the profile had memory enabled, regardless of which nudge
fired, so a skill-nudge fork held remove/replace on MEMORY.md it was
never asked to use; combined with the memory tool's near-limit
'consolidate now' hint, an unattended fork deleted standing rules with
no user in the loop.

- Pass review_memory from spawn_background_review_thread through
  _run_review_in_thread/_run_review_fork into _review_tool_whitelist;
  a skill-only review no longer gets the memory tool at all.
- Fail-closed operation gate in memory_tool: a background-review fork
  may add, never replace/remove (single or in a batch) — consolidation
  decisions reach a human via the review summary instead.
- Keep the deny/prompt wording in sync with the whitelist so a
  memory-less review doesn't advertise memory.

* fix(review): distinguish explicit /refine from unattended reviews and surface staged consolidations

Review follow-up on #105944 (#105921):

- explicit /refine forks now run under the refine_review write origin
  (explicit flows from the CLI/gateway handlers through
  _spawn_background_review_now and spawn_background_review_thread down
  to build_cache_parity_fork), so a user-requested review keeps the
  full memory operation set; only automatic reviews stay behind the
  unattended delete gate.
- the unattended delete gate now stages the denied replace/remove (or
  whole batch) into the pending store instead of dropping it: the
  fork's own review summary is never published, so a plain denial lost
  the consolidation request with no surfacing path. The staged proposal
  carries a proposal_staged marker that summarize surfaces as an action
  line, and a staging failure still fails closed to a plain denial.
- regression tests: explicit-path origin pass-through, refine_review
  keeping replace working, near-limit denial end to end (add rejected
  by budget -> replace staged -> proposal surfaces, store unchanged).

* fix(review): keep /refine under the background_review origin; attendedness is its own flag

The salvaged commit forked an explicit /refine under a new "refine_review" origin so
the memory delete gate would not treat it as unattended. But is_background_review()
is the key for every other review guard — skill_manager_guards (curator-owned-only,
read-before-write), skill_manager_tool (archive instead of rmtree), skill_ledger
actor, write_approval staging, the [auto] tag — so a /refine fork silently escaped
all of them.

Carry attendedness separately: the fork keeps origin "background_review" and sets
_review_attended; turn_context binds it beside the origin ContextVar; the memory
gate keys on the new is_unattended_review(). Also run the gate AFTER
_validate_single_op / the operations list check, as memory_tool's own docstring
requires, so an invalid replace is rejected now rather than staged and failed at
approve time.

* fix(sessions): serialize fresh FTS bootstrap

* fix(sessions): restore trigram after deferred bootstrap

* refactor(state): drop the table-exists probe made dead by the early return above it

* chore: map portavales's contributor email (#105694 salvage)

* fix(loop): re-anchor current_turn_user_idx after the alternation repair merges rows

prepare_iteration() runs repair_message_sequence_with_cursor() before each API
call; the repair merges adjacent user rows in place (after a compaction, the
role=user summary sits next to the protected first user message). The loop's
current_turn_user_idx was recorded at turn start, so after a merge it points
past the current user row: the per-turn context injection (prefetch/plugin
context) silently misses it, and hosts that settle the transcript by this index
(hermes-webui) write the current user turn to the FRONT of the context —
rewriting the prompt's leading messages every turn (0% prefix-cache hits at
200K+ tokens, ~100 s re-prefill per turn) and duplicating the user's question.

The in-loop compression restart path already re-anchors; do the same after a
repair that changed the list: reanchor_current_turn_user_idx (last user row
carrying this turn's text), return the index through the IterationPrep verdict
so the loop state picks it up, and mirror it into agent._persist_user_message_idx,
which hosts read when the result carries no index. The new phase parameters
default to None so direct callers keep their signature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gp366ijf39n4UUtJhZuMh

* feat(loop): export {turn_id, current_turn_user_idx} on every result envelope

Hosts that settle their own transcript by index (hermes-webui) cannot prove which
row of result["messages"] is the current user turn once this loop rewrote history
(alternation repair, compaction, post-turn micro-compaction): the instance-side
_persist_user_message_idx predates those rewrites, and a text match relabels an
identical historical prompt and claims its old answer. Only the producer can
assert the coordinate against the exact list it returns.

run_conversation now wraps the turn (_run_conversation_turn) and stamps the pair
through export_current_turn_boundary on every envelope that leaves the loop
(success, partial/error, interrupt, retry-exhausted, tool-limit, preflight
timeout, codex runtime), computed on the final messages after finalize_turn and
micro-compaction. The pair is exported only when the addressed row is this turn's
user message verbatim (reanchor's last-match rule); a rewritten row exports
nothing so hosts fail closed. The final index is mirrored into
_persist_user_message_idx for the persist override.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gp366ijf39n4UUtJhZuMh

* test(agent): prove the re-anchor through prepare_iteration; reuse the compaction _reanchor

The salvaged regression test exercised only repair_message_sequence and
reanchor_current_turn_user_idx — pre-existing helpers — so reverting the fix left it
green. It now drives prepare_iteration on a real AIAgent with adjacent user rows and
asserts the returned index addresses this turn's row and mirrors into
_persist_user_message_idx (red without the re-anchor: IndexError).

Both re-anchor sites (repair and compression restart) call
turn_context_compaction._reanchor instead of inlining "reanchor + mirror", so they
cannot drift. The export tests fold into one parametrized invariant plus the
run_conversation envelope test; the WHAT-restating comment shrinks to the WHY.

* fix(gateway): preserve shared MCP visibility across profile reloads

* fix(gateway): register shared MCP tools per profile

* fix(mcp): a profile only adopts a shared connection whose credentials match its own config

_same_server_route compared config_fingerprint alone, which by design excludes
env/headers/auth (so the schema cache survives a token rotation). Profile B with the
same URL but different headers/env therefore adopted profile A's live connection and
called tools as A. _connection_identity = route fingerprint + env + headers + auth mode,
used by both the adopt and the stale-removal checks.

Also collapses the three writers of _server_tool_scopes to two: the adoption loop
re-implemented in mcp_tool_discovery._select_new_servers is dropped —
register_connected_into_current_scope (which runs first in register_mcp_servers) is
the single adopter, and _register_candidates records scope for freshly registered tools.

* fix(gateway): ledger-bracket the queued-lane final send

When a follow-up is queued behind a turn, the first response is delivered by
the queued lane before the follow-up runs. That lane called adapter.send bare
and discarded the result: no delivery-ledger obligation was recorded, so a
final refused there (flood control, a transport that had just died) was lost
for good. Neither the boot sweep nor the runtime redelivery could see it and
the follow-up ran as if the answer had landed.

Route the queued lane's text send through the adapter's _send_final_text, the
same ledger-bracketed method the normal lane uses: the obligation is recorded
before the send under the id the normal lane would compute for the same turn,
the result finalizes it, and the reply is marked notify-worthy like every
other final. The reconcile-by-edit path is unchanged; adapters without the
base contract and sends without a session key keep the plain send.

* fix(gateway): key the queued-lane obligation on the raw inbound id

The queued lane's ledger bracket used the reply anchor as the obligation's
message reference. The anchor is None wherever replies are not used (Telegram
forum topics, Slack reaction handoffs), so two turns in one topic answering
with the same text shared an obligation id and the second record overwrote
the first's outstanding row; the id also differed from the normal lane's.

The lane now runs the adapter's record / send-with-retry / finalize sequence
itself, keyed on turn_ctx.inbound_message_id like the normal lane, while the
anchor stays the reply target. Tests cover the forum-topic identity, the row
being `attempting` while the send is in flight, and the call site passing
both ids.

* fix(gateway): carry the raw inbound id through a chained queued turn

Round-2 review. _run_agent_deliver_first_response passed the turn's inbound id to
the queued lane, but the recursive _run_agent for a chained follow-up did not, so
the chained turn ran with inbound_message_id=None. In a Telegram forum topic (no
reply anchor) two chained follow-ups answering with the same text would then key
their queued-final obligations on None and collide. The recursive call now carries
pending_event's raw message id, with a test on the chained path.

* test(gateway): let the queued-native-image fake accept the persist kwargs

Now that a queued follow-up carries its raw inbound id, the gateway passes
persist_user_platform_id to run_conversation for that turn (run_turn_runner.py
only adds it when inbound_message_id is set). The real AIAgent accepts it; the
test's fake did not. Accept **kwargs, matching the real signature.

* fix(gateway): ledger a queued chain's terminal reply under its own inbound id

The outer final send is bracketed by the adapter against the event that OPENED
the chain, so a terminal reply was recorded under the first message's id. When
two turns of one chain answered with the same text, the terminal reply computed
the earlier row's obligation id, replaced its outstanding row and marked it
delivered, so a first reply the platform had refused was never redelivered.

MessageEvent gains a documented ledger_message_id that the obligation hash
prefers, the queued follow-up returns the terminal turn's inbound id (innermost
wins on nested chains), and the handler sets it before the adapter brackets the
send. Reply routing is untouched: the anchor still comes from the event.

Three of the four new tests fail without this change; the whole tests/gateway
suite shows the same failure set before and after.

* refactor(gateway): one send_final_ledgered bracket for the normal and queued final lanes

The queued lane re-implemented _send_final_text's record / send-with-retry / finalize
sequence by duck-typing four private adapter members from gateway/. Lift the bracket
into a public BasePlatformAdapter.send_final_ledgered(event, session_key, text,
metadata, *, reply_to, is_ephemeral_response); _send_final_text keeps only the
ephemeral-delete tail on top, the queued lane calls it with the inbound-id ledger event.
ledger_message_id is a real dataclass field now, read directly.

Tests trimmed from 18 to 8 invariants (bracket recorded+delivered; flood refusal stays a
failed ledger row; forum-topic identity; plain adapter keeps plain send; normal-lane
parity; chained/terminal/deeper-chain inbound ids). Still 6 red with main's
run_notifications.py swapped in.

* fix(state): guard close-time checkpoint for replaced/deleted-generation handles (#105670)

- close() and _try_wal_checkpoint() now skip when _db_replaced or _db_wal_generation_lost
  (previously only _db_corrupt was checked) — prevents checkpointing stale-generation frames
  into the main DB, which is the shutdown-time damage reported in #105670
- _halt_if_db_generation_changed() calls _disable_close_time_checkpoint() alongside the flag
  set (3.12+: disables SQLite internal last-connection checkpoint too)
- Regression tests: halted handle must not run explicit PRAGMA checkpoint on close(),
  halt must call setconfig(NO_CKPT_ON_CLOSE), periodic _try_wal_checkpoint() must skip

* refactor(state): drop the ruff-format reflow from the checkpoint guard, keep the ~20 semantic lines

The cherry-picked commit re-wrapped hermes_state.py wholesale (+527/-131 for a
fix of about twenty lines). Restore main's layout and re-apply only the fix:
disable the close-time checkpoint on both generation-loss halts, gate the
periodic checkpoint on the sticky generation flags, and name the quarantine
reason at close.

* test(state): mark the checkpoint-guard tests linux_only instead of a bare skipif

AGENTS.md: a bare skipif(sys.platform != linux) is never listed by
scripts/ci/list_os_marked_tests.py, so the tests would run nowhere on the
OS lanes. The marker is the contract.

* fix(state): the deferred FTS rebuild retry is quarantined by the same rule as the checkpoints

retry_deferred_fts_recovery gated only on _db_corrupt ("mirrors _try_wal_checkpoint /
close") — after this PR it no longer mirrored them: on a replaced/lost-generation handle
the periodic housekeeping tick still ran FTS DDL/DML + commit, the same split-brain write
class as the #105670 checkpoint. One SessionDB._quarantine_reason() now decides for the
periodic checkpoint, close(), and the FTS retry, with the halt path's precedence
(replaced before generation loss) and the operator wording in one place.

Test: the periodic-checkpoint case folds into the close test (same setup), which now
also proves the FTS retry returns False without touching the file; the mutation with
main's schema sibling swapped in returns True (a rebuild ran).

* chore: map albert748's contributor email (#104444 salvage)

* fix(agent): persist /steer as a standalone user message

`apply_pending_steer_to_tool_results` used to smear the steer text onto
the last `role:tool` message's content. That tool row had already been
flushed to the session store and carries `_DB_PERSISTED_MARKER`; the
append-only persistence never rewrites it, so the replayable transcript
diverged from the live request bytes at the injection point — resumed
sessions (surface switch / process restart / background-review close)
missed the provider prompt cache (75-85% hit) and the user's mid-run
instructions were never part of the durable history.

The steer is now emitted as a standalone `role:user` message (marker
text preserved):
- role alternation stays legal: assistant(tool_calls) -> tool -> user is
  the documented 'user jumped in mid-run' pattern that
  `repair_message_sequence` deliberately keeps;
- the appended dict carries no `_DB_PERSISTED_MARKER`, so the next
  `_flush_messages_to_session_db` writes it to the session store —
  transcript bytes and replayed history finally agree, and the steer
  becomes searchable/retrievable like any other user message;
- the no-tool-result fallback (interrupt) still requeues the steer, which
  the caller then delivers as a normal next-turn user message.

Tests: TestSteerInjection updated for the new shape plus a persistability
assertion (no marker => flushable); tool-batch-segmentation malformed
scenario updated. steer + segmentation suites: 67 passed, 1 skipped.

* test: keep the steer suite on the canonical patch targets, not PLUGIN-COMPAT pointers

The cherry-picked commit carried an unrelated hunk repointing three patch()
targets back to run_agent.* — those are PLUGIN-COMPAT re-exports, off limits
in-tree (scripts/check_compat_pointers.py; removed 2026-09-14). Keep main's
model_tools.* / agent.process_bootstrap.OpenAI targets.

* fix(agent): the pre-API-call /steer drain also stops smearing the persisted tool row

Second site of the same bug class #104444 fixes in apply_pending_steer_to_tool_results:
_inject_steer_into_newest_tool_result (the drain that runs when a /steer lands during an
API call) mutated the newest role:tool row in place. That row was already flushed
append-only, so the replayed history diverged from the live request bytes at the
injection point and broke the prompt cache exactly like the post-batch path.

Deliver it the same way: a standalone user row inserted right after the newest tool
result (not yet persisted, so the next flush writes it to the transcript). Restash when
there is no tool row yet, unchanged. Stale comments claiming steer lands "in the newest
tool result" and agent/AGENTS.md's alternation rule now describe the real shape.

* fix(agent): a persisted /steer row survives the next prompt's alternation repair; typed for history

Both steer sites now build the row through one helper, prompt_builder.steer_user_row:
a role:user row with display_kind="steer" and no leading blank lines. The alternation
repair (_merge_consecutive_users) skips a steer-typed prev row, so a run that ended
right after a steered batch (Ctrl-C, interrupt) does not get the next real prompt
merged INTO the already-persisted steer row — which would have rewritten it in place
and re-broken live≠replay parity, the exact class this PR fixes.

TUI/desktop history projects the steer row as the user's own words instead of the
model-facing marker wrapper; 'steer' joins the display_kind union. The compression
anchor scan keeps its tool-row branch for transcripts persisted before this change and
its docstring says so.

* fix(tui): resolve default profile session names

* fix(tui): preserve names for custom profile homes

* fix(tui): fail closed on unavailable profile targets matching custom root basenames

* test(tui): add coverage for custom default roots, real session db stamping, and sibling isolation

* fix(tui): a real named profile "hermes" is not swallowed by the legacy-basename alias

"hermes" matches the profile-id regex, so canonicalising it unconditionally at the RPC
boundary misrouted a genuine <root>/profiles/hermes to the default profile. Alias only
when no such named profile exists; ".hermes" can never be a real id and stays aliased.

Also: profile_name_for_home collapses its duplicated pre/post-resolve block into one
loop over (path, resolved path) and drops the bare "parent named profiles" fallback
that bypassed named_profile_home's root check; _profile_home goes back to main's
single resolve() comparison; the symlink-loop assertion in the target-unavailable
test is no longer wrapped in a try/except that could silently skip it.

* fix(profiles): a stored <root>/profiles/<name> home names its profile even when the root carries no markers

CI: tests/test_tui_gateway_server.py::test_ensure_session_db_row_stamps_profile_name used a bare tmp
root; profile_name_for_home fell through to None and the row was stamped default. The stored home
is authoritative (its owner resolved it), so the profiles/<name> shape is sufficient.

* fix(cli): honor --resume in one-shot mode (#105892)

The -z exit path accepted --resume/-c in the parser but never forwarded
args.resume: every resumed one-shot turn silently started a fresh session,
so each wire request carried only [system, current user] and the model
lost all prior context (reported against Ollama/custom OpenAI-compatible
endpoints, but provider-independent).

Normalize session args (latest/title/--continue/--in + cwd restore) via
the chat path's _resolve_chat_session_args before the oneshot exit path
takes over, then load the resumed transcript in _run_agent through the
same contract the interactive CLI uses (compression-chain redirect,
safe-resume guard, session_meta filtering) and continue the existing
session id instead of creating a new one. An explicit --resume of an
unknown session now fails loudly instead of starting fresh.

* fix(cli): keep the resolved session id when a resumed oneshot session is empty

Review finding on #105957: `_load_resume_target` returned None for a
resolved session with no stored messages, so `hermes -z "hello" -c <title>
--create-if-missing` recorded the turn under a freshly minted session id and
the just-created titled session stayed empty. Preserve `resolved` unconditionally — the interactive /resume path keeps the selected id for an
empty session too; only the history replay is empty. Regression tests pin the
durable id for both a plain empty session and an empty compression-chain head.

* fix(cli): restore stored session runtime and reopen ended rows on oneshot resume

Review fixes (#105957):

- A resumed one-shot ignored the session's stored model/provider runtime:
  _resolve_model_and_provider()/resolve_runtime_provider() ran before
  _load_resume_target(), which only loaded the session id + transcript, so an
  ambient config (e.g. openrouter/ambient-model) served the resumed transcript
  instead of the stored route (custom:stored/stored-model). The stored runtime
  is now applied before runtime resolution, with the same contract as the
  interactive _restore_session_model(): stored model/provider/base_url/api_mode
  replace the ambient choice unless --model was passed explicitly, and a
  changed provider drops the ambient api_key so resolution re-fetches
  credentials for the restored endpoint.

- Passing the resumed id to AIAgent did not reopen the already-ended session
  row: end_session() only writes rows whose ended_at is null and the
  existing-row upsert never clears the end fields, so the resumed turn was
  recorded under a session that stayed closed and its new lifecycle boundary
  was lost. _load_resume_target() now reopens the row (best effort), same as
  the interactive resume does before continuing.

* refactor(cli): one stored_session_route for interactive and one-shot resume

_apply_stored_session_runtime was a line-for-line copy of the first half of
_restore_session_model (stored-model guard, session_gateway_runtime, bare-custom heal,
model/provider-changed check). Extract that pure decision into
cli_model_switch_mixin.stored_session_route and have both resume paths call it; the
one-shot keeps only the _ModelChoice mapping and the drop-ambient-key rule.

main.py stops re-normalising `resume` — _resolve_chat_session_args already did.
Tests trimmed from 20 to 13: near-duplicate unit tests of the private helpers go, the
end-to-end _run_agent contracts (stored runtime + reopen; explicit --model wins) and the
empty-session-keeps-id case stay.

* fix(cli): keep the no-stored-model early return ahead of the route read

CI: tests/cli/test_cli_resume_command.py builds bare HermesCLI objects without .model; the
refactor read self.model before the stored-model check the contributor's code made first.

* test(agent): the worker-start-failure test intercepts the callback worker again

`kwargs.get("target") is sched._run_callback` is always False (a bound method is a fresh object
per access), so the fake never returned Boom and the _dispatch failure branch went untested;
the test passed on the normal worker. Compare with == and assert the interception happened
(mutation: retiring the handle on start failure now fails the test).

The thread-count assertions sampled while per-fire workers were still live; quiesce every
handle with cancel(wait=) before sampling so the count is deterministic (AGENTS.md: timing tests
must not assume a quiet runner).

Follow-up to #106308.

* fix(loop): the turn-boundary export skips preflight-timeout envelopes and stops re-anchoring the persist index

Follow-up to #106312. _preflight_timeout_result carries the prior history without this turn's
user row (#7100); with a repeated prompt ("continue") the verbatim scan resolved to the
historical copy and exported it as this turn's proven boundary — the exact relabeling the export
exists to prevent. Nothing is exported for that envelope now.

The trailing `agent._persist_user_message_idx = idx` ran after finalize_turn had already flushed
the transcript, so it never influenced a persist and the next turn reset it: dead state, removed.

* fix(gateway): the ephemeral delete goes to the adapter that sent the final

Follow-up to #106316. send_final_ledgered resolved the live adapter internally and
_send_final_text resolved it a second time for _schedule_ephemeral_delete; a reconnect between
the two sent the delete to a transport that never owned result.message_id (the ownership rule
_final_delivery_adapter documents). The bracket now returns (result, adapter).

The queued lane carried the ledger identity through MessageEvent.message_id while the PR added
ledger_message_id for exactly that; it now uses the typed field, and the ledger read is
getattr-tolerant of duck-typed events (a missing attribute was swallowed as "ledger skipped").

* fix(state): VACUUM is gated by the same quarantine rule as the checkpoints

Follow-up to #106315. vacuum() ran PRAGMA wal_checkpoint + VACUUM + wal_checkpoint(TRUNCATE) on
self._conn with no quarantine check; the only guard it inherited (optimize_fts raising
DeletedWalGenerationError) was swallowed by its own try/except and the rewrite proceeded on the
split-brain handle. Mutation on main: vacuum() returned 2 and rewrote pages after the write stop.

* fix(agent): a /steer row is human input for every user-turn predicate

Follow-up to #106317. Typing the steer row (display_kind="steer") for the renderer and the
alternation-repair guard collided with the convention that any display_kind on a user row means
scaffolding: is_user_originated_turn / _is_actionable_user_turn / split_user_originated_turn
returned False for it (tail anchoring, auto-focus, dispatcher views, resume counts) while
_is_real_user_message returned True (anchor restoration) — the two predicate families disagreed
on the same row, and list_recent_user_messages (/undo, /rewind) skipped it in SQL. A steer
carries full user authority; the steer kind is now whitelisted in all four.

Also: the pre-API drain's requeue tail reuses _requeue_pending_steer instead of a copy; the TUI
history projection compares against STEER_DISPLAY_KIND; the steer() docstring describes the row.

* fix(state): guard vacuum() and optimize_fts() against quarantined SessionDB handles

A quarantined/replaced/split-generation handle must never run a full-file rewrite or an FTS5
'optimize': both read damaged or foreign pages and commit the result back, turning contained,
diagnosable corruption into an amplified one. Same guard _execute_write applies to every write.

Salvaged from #102092 onto current main: the _try_wal_checkpoint half landed via #106315's
_quarantine_reason(), so only the two rewrite sites remain.

* fix(auth): preserve independent same-account OAuth grants

* fix(auth): carry pool-row lineage into the provider-block heal

With account-identity matching gone, the providers.<id> block consolidation
only fired on shared token material. A historical fork (same copied pool-row
id, profile rotated, both pairs diverged) then healed the pool row into root
but left root's providers.openai-codex block on the spent pair; root's next
load_pool() re-seeds its device_code row FROM that block and undid the heal.

_HealPass now records that a profile pool row matched root by copied id or
shared tokens and passes that verdict to _heal_forked_provider_block, which
accepts it as lineage proof. No account-identity guessing is restored; an
independent same-account grant (no id/token match) is still left alone.

Follow-up to simpolism's #106177.

* fix: address 6 P1 findings from merged PR review threads

- repair_controller.py: build the retirement completion command through
  _governed_command_prefix() (adds -P) instead of a bare `python -m`
  invocation, matching the sibling identity command; an untrusted
  exact-head PR worktree could otherwise get prepended to sys.path.
- cli.py doctor probe: report worker_completion_policy failed whenever
  HERMES_SAFE_MODE is active, since dispatched workers inherit it and
  PluginManager skips all plugin discovery under it regardless of what
  the profile config declares.
- worker_contract.py: default a manifest's missing `name` to its
  directory name before comparing, matching parse_manifest_file()'s
  actual runtime behavior, so a name-less override plugin.yaml is no
  longer treated as absent.
- worker_contract.py: fail closed when a profile's plugins.enabled/
  disabled list still contains an unexpanded ${VAR} reference, since
  expanding it against doctor's own environment doesn't guarantee the
  dispatched worker's .env resolves it the same way.
- methods_profiles.py: catch SystemExit (not just Exception) around
  _write_raw_config_values(), which raises SystemExit for managed-scope
  keys; the shared TUI/Desktop/dashboard RPC backend must not exit on a
  refused profiles.configure write.
- config.py _preserve_env_ref_templates(): match a modified, reordered,
  unnamed list entry to the loaded item it most structurally resembles
  instead of the raw item at its new output position, so a sibling's
  unchanged ${VAR} template isn't dropped into plaintext on save.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(tui-gateway): /review shows its reviewer in the Desktop subagent stack

`slash.exec` runs on the RPC pool, outside any turn, so `/review` dispatched the
reviewer with no HERMES_UI_SESSION_ID and no steer authority bound. delegate_task
registered the child with `owner_session_id=None`, `subagent.list` (owner-scoped)
returned nothing for the parent session, and the Desktop status stack's 5s
snapshot poll reconciled the live `subagent.start` row away — the user saw only
"Review started. Results will return here." with no subagent card.

Bind the same session identity a turn binds (`_set_session_context(...,
ui_session_id=sid)` + `_current_runtime_session_record`) around `start_review`,
and clear it after. The reviewer now registers under the parent sid with the
request transport as authority, so `subagent.list`, steer, stop and the Desktop
roster all see it.

Live repro (tui_gateway stdio, real OpenRouter reviewer):
  before: registry owner_session_id=None, owner_transport=NoneType;
          subagent.list -> {"subagents": []}
  after:  owner_session_id=<parent sid>, owner_transport=StdioTransport;
          subagent.list -> [{"goal": "Review recent work", "status": "running", ...}]

* fix: address remaining P1 findings (dispatch generation, completion guard, context compressor)

- feedback_retirement.py: extend governed retirement to pr_local_ci
  receipts too -- audit-pr rejects a non-OPEN PR identity outright, so a
  card whose PR closes mid-audit had no other path to clear its pending
  ledger row and stayed stuck forever.
- controller.py: reintroduce _dispatch_generation() (lost track of
  ClaimLease.reopened during an earlier merge -- version > 1 is the same
  signal) and wrap all 3 create_or_get_task() call sites, so a reclaimed
  dispatch gets a fresh Kanban identity instead of returning the
  pre-closure done card.
- controller.py _is_staged_auto_dispatch_task(): also require no real
  "blocked" lifecycle event, so a repair worker's legitimate kanban_block
  call (same status/idempotency-prefix/evidence shape as a never-run
  staged card) isn't misclassified as a failed staging promotion and
  bounced back to ready.
- kanban_completion_policy.py: load the bundled github_pr_feedback
  package by file path instead of a bare import, so the control-plane
  completion-guard fallback works even in a dispatched worker profile
  that doesn't itself enable the plugin (previously ModuleNotFoundError,
  uncaught).
- context_compressor.py: scan the actual handoff-expanded window
  (scan.tail_start) for the current-task assignment summary instead of
  the initial compression window, so a newer assignment carried by a
  later-consumed handoff isn't shadowed by a stale in-window match (or
  missed entirely).
- test_run_agent.py: fix a NameError from an earlier merge -- an
  undefined mock_record_failure reference where the test actually needs
  hermes_cli.kanban_db.block_task patched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(desktop): SSH remote backend stops following the host's sticky active_profile

A Desktop-owned `hermes serve --isolated --ssh-session-token-file ...` child
is spawned with an explicit `--profile <name>` when the connection names a
remote profile, and with no flag for the remote root home. Without the flag,
`_apply_profile_override` read the remote host's sticky `active_profile`
file and re-homed the backend into whatever profile the user last selected
on that machine's CLI. Settings then read one config.yaml while the remote
gateway wrote another, so model picks and toggles "didn't stick".

Treat the SSH token flag as a fixed-identity marker, the same way
supervisor-launched gateway children are (#74872): a Desktop backend's
profile is chosen by the client, never by the host.

Live repro (before/after, temp HERMES_HOME with active_profile=foo):
  serve --isolated --ssh-session-token-file ...   hermes_home=<root>/profiles/foo -> <root>
  same + --profile foo                             hermes_home=<root>/profiles/foo (unchanged)
  serve (no token file, user CLI)                  hermes_home=<root>/profiles/foo (unchanged)

* fix: verification evidence ledger is inert while verify_on_stop is off

The ledger in verification_evidence.db exists only to feed the verify-on-stop
guard, but the recorder kept running on every foreground terminal command and
every file edit after #53552 turned the guard off by default. Users who never
opted in still accumulated a multi-MB database (7 MB / 4.6k rows on one install).

Every ledger entry point (record_terminal_result, record_verify_run,
mark_workspace_edited, verification_status) now checks…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/compression Context compression and continuation sessions comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants