hermes.gateway.busy_arrival.count: one row per human message that reaches a
chat whose turn is still running (the shared runtime's busy dispatch),
recorded once its fate is known: queued (ran later as its own turn),
steered (steer or interrupt-mode redirect into the running turn),
interrupted (hard interrupt, then ran next), merged (folded into another
busy message of the same burst), dropped (/stop, /new, refused, queue cap),
with the wait from arrival to the moment it reached the agent.
hermes.engagement.day.count gains gateway_peak_busy_chats_bucket: the most
chats of the owning profile with a turn running at once that UTC day, kept
in memory from the runner's running-turn set at every turn boundary
(_persist_active_agents, moved to the agent-cache mixin so the gateway.run
facade does not grow) and folded into the profile's engagement day state.
Both are opt-in through the existing shared-metrics gate; no chat ids or
session keys leave the process.
_release_running_agent_state released the cross-process active-session
lease inline at every turn end: registry file lock, JSON read + prune (a
liveness probe per entry) and an atomic rewrite, on the gateway loop. With
many turns finishing it was a recurring stall (active_sessions.py
_read_entries / _write_entries under release in the stall sampler).
Releases now go to one ordered worker (gateway/active_lease_io.py). Order
is kept; a release that lands after the same session's next acquire is a
no-op because the registry drops by lease id and that acquire re-keyed the
entry. The lease pinned its registry path at acquisition, so the worker
needs no profile scope. The acquire at turn start stays synchronous: it is
the cross-process exclusivity check and must complete before the turn's
first await (the in-process duplicate-turn guard depends on it).
The delegate_task schema is built from the active backend and is cached prompt,
so a session-only /delegation switch changed routing but not the schema.
- Remove the session-only backend override (get/set_session_backend,
session_delegation_config, the /new boundary clear). delegate_task routes on
delegation.backend.
- set_global_backend(name, *, session_key='', apply_now=False): saves config;
apply_now rebuilds that session's tools at its next turn (after the pin
restore, so fresh agents rebuild too) and re-pins them, one opt-in cache break.
- The undeclared-parameter refusal names the fix: /delegation <b> --now or /new.
- Docs: delegation guide + provider-plugin API table.
A /delegation pick is session-scoped like /model, but it lives in
agent.delegation_backends keyed by session_key, and the gateway's route key
survives /new, /reset, /resume, idle/daily auto-reset and the
compression-exhausted reset. Nothing popped it at those boundaries, so the
next conversation in the chat kept overriding config.yaml's delegation.backend
(Greptile on #143126, relayed in #142563).
The conversation-boundary funnel (_clear_conversation_scope), which already
clears the /model override for every one of those boundaries, now drops the
session's backend pick too. Other chats keep theirs.
publish_settled_info evicted the /model --once (and /moa) agent from
inside its own turn. _evict_cached_agent pops the entry but never tears
down an agent that is still running, and nothing retried, so the agent's
clients, child agents and an interactive Codex app-server child outlived
every sweep.
The evict now asks for release_after_turn: a running agent is parked on
its turn slot, and _release_running_agent_state releases it (soft, in the
owning profile's scope) once that slot clears. It still leaves the cache
at once, so resume and Desktop never read the one-turn route off it.
Review finding by @kshitijk4poor on #140969
Refs #134889 (CLI-36)
Refs #140719
The sidecar note asked the default profile's Discord adapter, so under multiplex_profiles a secondary profile's bot never learned it was in a voice call: the voice binding lives on the bot that joined, and the note came back empty. Resolve the adapter through _delivery_adapter_for, the same helper the voice paths in run_voice.py already use, so the note follows the bot that received the turn.
(cherry picked from commit b675bef05caef4d02f975baf9679e2fac1797ac9)
Feed the same internal or inherit_channel_pin decision into
_pinned_session_context_prompt as _pinned_channel_inputs, so a
synthetic goal/heartbeat/resume turn (which strips message_id, a
_ephemeral_change_key input) doesn't flip the session-context block
and back on the next human turn. Also moves inherit_channel_pin to
append after reply_expected for MessageEvent positional compat.
Goal continuation, heartbeat due-prompts and /goal resume events are built
non-internal (they must still pass authorization) with channel_prompt=None.
_pinned_channel_inputs() only reused the pin for internal events, so these
synthetic turns recorded (None, parent_chat_id) as the new channel_pin,
dropping the channel prompt and channel_overrides for that turn and flipping
them back on the next human turn (#125763/#124179-style A->B->A: shorter
ephemeral prompt, changed _agent_config_signature, cached-agent rebuild,
prefix-cache miss).
Add MessageEvent.inherit_channel_pin, independent of `internal` (which also
gates authorization bypass and touch_activity), and set it on the three
producers that carry no channel inputs of their own. _pinned_channel_inputs
now reuses the pin for internal OR pin-inheriting events.
Fixes#126109
/stop, /undo, /model and a config model change evict the cached agent and
clear the in-memory session-context pin, but keep the channel pin.
_rehydrate_prompt_pins only restored the durable snapshot when BOTH pins
were missing, so the first internal event after such an eviction (kanban
wake, delegation completion, watch notification) found no pin to reuse
and rendered the context from its origin-only source: no chat name, no
user name. The next human turn re-keyed back. The ephemeral system prompt
changed twice (A->B->A), each change rebuilding the cached agent and
re-prefilling the whole conversation.
Rehydrate now runs whenever the context pin is missing, and keeps a live
channel pin. Human turns never rehydrate, so an evicted agent's next human
turn still re-renders from its real source.
45 conflicts, all from #135177 (lefthook + `ruff check --fix` over 2,366 files). No other main commit
touches a conflicted file. Resolution: the branch side of each file, then main's exact autofix
(ruff 0.15.10, `--config ./ruff.strict.toml --fix`) on it — rewrites only (typing generics, noqa
drops, import forms), no import removed. tests/cron/test_bot_chat_cli_home.py stays deleted
(1cc9cb9199 retired its lane).
90219bdb40e5 restores the persisted routing value, OFF included, before the frozen `--yolo` launch
policy can seed. That fixed a revived revocation. But `SessionEntry.yolo` defaulted to False, and
every entry nobody toggled stores False: branch, reset, legacy migration, cron, new sessions,
every pre-upgrade row, and the /new boundary funnel (which wrote False). All of those now read as
"owner turned it off". So a `--yolo` launch was lost after an owner restart, on /branch, and after
/new. That breaks `apply_launch_yolo`'s "seeded once per session boundary" contract. It fails safe
(approvals are asked again), but it is a behaviour change.
`SessionEntry.yolo` is now Optional[bool]:
- True/False = an explicit /yolo toggle. An explicit OFF still beats a `--yolo` launch across
restarts, so an owner /yolo OFF is never revived.
- None = nobody toggled it, so the launch policy applies. `restore_gateway_yolo(key, None,
launch=...)` seeds it through `apply_launch_yolo` (once per boundary).
- The boundary funnel resets the toggle to None instead of False.
- `create_local_session` no longer copies the launch policy into the toggle.
Wire format: `yolo_toggle` carries the tri-state. `yolo` keeps its old meaning (True only when
ON), so older readers see the same value. Rows without `yolo_toggle` (written before this change)
map ON to True and False to None. False was the default for "never toggled" there, and it followed
the launch policy before 90219bdb40e5.
Tests (tests/gateway/test_yolo_launch_tristate.py, dA2's probes):
- An untoggled entry keeps launch yolo in a fresh interpreter, while an explicit OFF stays off.
- Launch yolo is re-seeded after a real `_clear_session_boundary_security_state`, but not after a
plain owner OFF.
Both fail on r5-picked. His test_yolo_restart_revocation, test_yolo_command, tools/test_approval_yolo,
managed-worker flags and busy-controls tests stay green.
Follow-up to 90219bdb40e5 (#135061)
Review: https://github.com/NousResearch/hermes-agent/pull/135061
Conflicts:
- gateway/run_turn.py: the branch moved the block to run_turn_prepare; main's b1a39a1b1b
restore_session_yolo call ported there.
- Desktop pool (main 167e9fdb84 no-idle-reap): the branch removed the local backend pool;
pool-retire.{ts,test.ts} and pool-limits-setting.tsx stay deleted, main.ts keeps no pool.
main's MSIX relaunch-waiter logging (bdb8f92f3b) auto-merged.
- e2e/core/no-idle-reap.spec.ts rewritten for the single local gateway: a profile dial stays on
the same gateway pids and descriptor past the old pool idle window (1 passed, 3.6m).
One /yolo contract: gateway-owned sessions (session_busy_controls.yolo_config -> toggle_session_yolo)
and managed workers (restore_session_yolo) use main's tools/approval_yolo.py; the launch --yolo seed
(apply_launch_yolo) moved there and is skipped under a process-wide --yolo.
The persist / restore / carry logic for session YOLO had grown three copies
(the CLI mixin, tui_gateway, the gateway runner), each with its own key choice,
ordering and error handling. tools/approval_yolo.py now owns it:
- toggle_session_yolo: flip or set, persist BEFORE the live flip (a racing
restore never revives an OFF), stored-copy-aware so a toggle after a restart
turns it OFF; a failed write logs a warning and never blocks the flip.
- restore_session_yolo: re-arm a persisted flag in a fresh process; skipped
under the frozen process --yolo.
- transfer_session_yolo: move the live flag to a continuation id.
- with_session_yolo: a row being created records a live flag (lazy first row,
compression child, /branch).
Each surface keeps only its storage: the session row for CLI/TUI/Desktop, the
routing entry for the gateway. Removed: HermesCLI._transfer_session_yolo /
_persist_session_yolo, GatewayRunner._restore_session_yolo, the TUI-only
restore helper, and the TUI's post-publish set_session_yolo carry (the
compression publish now writes the child row with the flag).
Closes a gap the unification exposed: a CLI manual /compress that rotated the
session dropped YOLO (the next dangerous command prompted, and a --resume of
the continuation came back with it off).
Brings #134209 onboarding (setup chat, /initiate-setup, setup_choose/start_chat cards) and
889eddcd86 (/yolo survives a gateway restart).
Conflicts (6):
- gateway/run_turn.py, tui_gateway/server.py: the branch moved these blocks into siblings;
main's edits ported there (run_turn_prepare: first-contact `message`/setup_command, the
_restore_session_yolo call after _hmwa_open_session; agent_factory: coding_selection
session-platform toolsets, setup_choose/start_chat lifecycle tools; session_registry:
_lazy_resume_info default route).
- tests/gateway/test_yolo_command.py: both tests kept.
- Desktop composer submit / slash / wiring: main's parked-card answering and submitWhileBusy
with the branch's busy-input policy in its steer branch; slash `hidden` folds into the
prepared-submission journal; wiring keeps main's onboarding chain without the removed pool.
Semantic fixes:
- gateway/session_busy_controls.yolo_config writes main's persisted SessionEntry.yolo before
the live flag, so an owner `/yolo` OFF is not revived by _restore_session_yolo next turn
(new test test_owner_yolo_off_is_not_revived_by_the_persisted_restore).
- wiring runKickoffSlash awaits the branch's Promise<boolean> slash dispatcher.
- de/es/fr/ja/zh-hant: translate the branch's composer keys main's overlay-completeness test
now requires.
/yolo on a messaging platform only flipped tools.approval's in-memory
set, so any gateway restart (hermes update, crash, systemd restart,
Desktop reopen) silently put the chat back on manual approvals while
the user still believed YOLO was on.
The flag now lives on the chat's routing entry (SessionEntry.yolo,
persisted in gateway_routing like the /model override). /yolo writes it
before flipping the live flag, materializing the entry for a
first-message toggle; every turn re-arms the live flag from the entry it
already resolved; switch_session carries it across compression-tip and
binding repoints; the conversation-boundary security funnel (/new,
/resume, /branch, auto-reset) clears the persisted copy with the
in-memory one so a restart can never revive a bypass the user dropped.
Also corrects the security docs, which still claimed /yolo sets
HERMES_YOLO_MODE.
The channel-override baseline from #131295 skipped the session /model tier and
normalized the expected model with the agent's current provider. Both disagree with
how the agent was built, which turned three cases main handled into wrong verdicts:
- a recorded Nous switch off a session /model choice was evicted (the baseline was
the channel model, so the (alias, backing) record never matched);
- a session-primary fallback that landed on the channel's model was kept;
- an OpenRouter primary that fell back to native Anthropic was kept, because the
channel slug normalized under the fallback provider equals the fallback model.
Resolve the baseline in the same order as _resolve_session_agent_runtime (session
/model, channel_overrides, global) and normalize it for the agent's primary provider,
the same route the welcome-host pin already uses. A session override whose provider
was unavailable this turn ran on the default route (the resolver drops it for that
turn and retries next turn), so it is the baseline only when its provider is the
agent's primary provider.
A model-name comparison cannot see a fallback that serves the configured model on
another endpoint; with a channel model that differs from the global one, #131295
turned that case from evicted into kept. A turn whose agent has
_provider_fallback_active (set only by _try_activate_fallback, cleared by
restore_primary_runtime) now evicts before any name comparison.
The baseline lives in GatewayAgentCacheMixin._fallback_baseline_model beside
_is_intentional_model_switch, which keeps gateway/run_turn.py under its code-health
line cap (the channel lookup from #131295 had grown it past main).
Session tier taken from #132238; both regressions diagnosed in review by @andrexibiza.
Co-authored-by: salch-cred <141555468+salch-cred@users.noreply.github.com>
Co-authored-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
The 10 s concurrent-load bound also hit slow but healthy first imports. A
second agent built while a provider's import took longer than 10 s got no
memory provider for its whole session, and with checkpoint_required every
compaction then failed closed.
The bound now applies only inside plugin_loader.bounded_load_wait(), which
the per-turn gateway memory identity signature uses. Other callers (agent
builds) wait for the in-flight load. Cycle detection still breaks a
cross-thread deadlock for every caller.
With the discord toolset loaded, a typed turn rendered the per-turn
triggering-message line and keyed the agent cache on message_id presence,
while slash, /thread and voice turns (no triggering message) did not, so
the pinned session-context prompt re-rendered on every switch between
them. The line is now static and the presence bit is out of the key; the
real id still rides the user message when there is one.
Reported by teknium1 in the #132758 review; fix shape from andrexibiza's #132758.
Co-authored-by: Andrex Ibiza, MBA <andrexibiza@gmail.com>
(cherry picked from commit 2a3f04a67d1aa6dc43628ce0959ddb699baa84f8)
gateway/session.py is the SessionStore facade assembled from session_*
mixins; new behaviour belongs in a topical sibling, not the facade.
PROMPT_PIN_VERSION, sanitize_prompt_pin and get/set_prompt_pin move
verbatim to gateway/session_prompt_pin.py (SessionPromptPinMixin).
SessionEntry keeps only the field and its (de)serialization; the agent
cache imports the schema from the defining module. No behaviour change.
_rehydrate_prompt_pins read the store synchronously from inside the
prepare-turn coroutine. get_prompt_pin takes SessionStore._lock, which
worker threads hold across routing-index DB writes, so the first
internal event after a restart (startup resume, kanban wake) could stall
the loop behind another session's save.
Rehydrate is now async, reads through async_session_store, and runs in
_hmwa_prepare_turn before the sync context-pin helper, which goes back
to its main-branch signature. _persist_prompt_pins hands the raw
snapshot to set_prompt_pin, the one validator.
The persisted pin carried rendered session-context bytes but not the
privacy.redact_pii they were rendered under. After a restart, the first
internal event restored and reused them verbatim, so bytes rendered with
redaction off reached the model after redaction was switched on. Base
re-rendered under the current setting after a restart.
The pin now records redact_pii (the in-memory ephemeral_pin becomes
(change_key, text, redact_pii); the durable schema requires the flag).
Bytes from a different policy are never reused, including by internal
events in the same process, which had the same bypass until the next
human turn.
Simplified on the way:
- sanitize_prompt_pin is the one validator; the snapshot builder and
rehydrate no longer re-check the shape by hand, and use
PROMPT_PIN_VERSION instead of a literal.
- Rehydrate runs once, from the context path, and only when this process
holds no pins for the key (a restart). Eviction clears only the context
pin, so an evicted agent still re-renders instead of reviving the
snapshot. The channel path no longer rehydrates or takes a session id.
- _persist_prompt_pins drops its synchronous get_prompt_pin pre-check,
which took the store lock on the event loop; set_prompt_pin already
no-ops an unchanged snapshot. Its call-count test goes with it.
- set_prompt_pin builds its candidate with dataclasses.replace like
set_model_override, and no longer accepts None (no caller cleared).
- The read-fence test folds into the restart/reset test.
Merge #8: 2,771 commits from main (4b05f764fa). Ports main's moved code into the
unified runtime (run.py/run_turn.py splits, tui_gateway server, backup/restore
split), keeps the detached-daemon Desktop lifecycle, and adapts main's new tests
to the owner/authority contracts (canonical session deletes, retired-id
tombstones, zero-writer browsing, hermes_yaml, platforms() markers).
The two pin helpers encode one rule (a human turn records, an internal
turn reuses) but took it in two shapes: _pinned_session_context_prompt
gets `internal` as a keyword while _pinned_channel_inputs took the whole
event and read getattr(event, ...) itself. Match the sibling:
(session_key, channel_prompt, source, *, internal), with the call sites
passing the event's values. MessageEvent.channel_prompt/internal and
SessionSource.parent_chat_id are declared dataclass fields, so the
getattr guards (including the new context-prompt call site) go too.
_pinned_channel_inputs copied the internal-turn source with a plain
dataclasses.replace, which drops the wire-invisible provenance
(transport ref, authorization home, identity). Under multiplexing the
copied source then reaches _run_agent / _delivery_adapter_for without
its identity and falls back to heuristic profile/adapter routing.
gateway/AGENTS.md requires source copies to go through
session_identity.replace_source; use it (late import, matching the other
gateway callers) and drop the now-unused dataclasses import.
The internal-event branch returned an unpinned render when no pin existed
yet, so sessions that only ever see internal turns (kanban/API-server
wakes, startup resumes) re-rendered the session-context prompt every turn
and lost the verbatim-reuse immunity base gave them. Reuse an existing pin
when present; otherwise fall through to render-and-pin as before.
Applies teknium1's inline review suggestion on #122048.
Internal events (kanban wakes, delegation completions, watch
notifications) carry a source rebuilt from the persisted origin: no
chat_name/user_name/message_id/parent_chat_id, and channel_prompt=None.
_pinned_session_context_prompt re-rendered from that source, so every
internal turn re-keyed the pin and the next human turn re-keyed it back
(A->B->A). Each flip rewrote already-sent system bytes and collapsed the
prompt cache to the static prefix. The same toggle happened through
channel_prompt and parent-keyed channel_overrides in the ephemeral
system prompt.
- _pinned_session_context_prompt(internal=True) reuses the existing pin
verbatim and never re-pins.
- Human turns record (channel_prompt, parent_chat_id) in
ConversationState.channel_pin; internal turns reuse them (main run and
the queued follow-up path).
tests/gateway/test_internal_event_pin_wiring.py drives the real
_handle_message_with_agent human->internal->human with _run_agent
stubbed. Both tests fail on main and pass here; dropping internal= at
the call site makes both fail again.
(cherry picked from commit 3b38527ae7f0f498ca265fe9f64fcbc1c8c7765c)
The messaging gateway's /model wrote only model + provider to the session row. A row the
Desktop/TUI had persisted with the Nous Portal route kept that base_url and api_mode, so resuming
a chat switched to openai-codex built provider=openai-codex on the Portal URL and posted
gpt-6-luna-900k to inference-api.nousresearch.com/v1/chat/completions: "Model 'gpt-6-luna-900k'
isn't available on ChatGPT or Codex Subscription".
- SessionDB.update_session_model writes the whole route (provider, base_url, api_mode) in both
shapes resume reads (top-level for TUI/Desktop, gateway_runtime for the CLI) whenever a
provider is given; the gateway /model passes the switch result's endpoint.
- Resume readers (TUI/Desktop, CLI, gateway rehydrate) drop a persisted base_url that is another
built-in provider's canonical endpoint, so rows already written by older builds heal.
- A gateway session override whose credentials failed to re-resolve is resolved for its own
provider on the turn instead of being layered over the default provider's runtime (which
produced openai-codex + Nous key + Nous URL). If it still cannot resolve, that turn runs on the
whole default route with the existing one-shot "Provider fallback" notice; the override is kept
and retried next turn.
#119406 made absent keys carry DEFAULT_CONFIG's value in the cache signature, but the
legacy bool branch still emitted None for max_snapshots/max_total_size_mb/max_file_size_mb
while `_checkpoint_agent_kwargs` builds that agent from DEFAULT_CONFIG — so migrating to
`checkpoints: {enabled: true}` rebuilt the cached agent for nothing. The dict/absent branches
were also re-implementing cfg_get's own semantics; one `cfg_get(cfg, section, key, default=…)`
covers absent section, non-dict section, absent key and explicit null. DEFAULT_CONFIG is
imported at module top (hermes_cli.config already is; gateway/restart.py sets the precedent).
The agent-cache signature reads the raw config file (no DEFAULT_CONFIG merge) and mapped
an absent key to None. Since #115986 shipped compression.threshold_tokens=256K, the
documented opt-out (`hermes config set compression.threshold_tokens null`) produced the
same None as "never set", so the signature did not change and live gateway sessions
kept compacting at 256K until the process restarted (community report on #115986).
Absent keys now carry DEFAULT_CONFIG's value — what the agent was actually built with —
so absent == explicit default (no spurious rebuild) and absent != explicit null.
Mechanical migration of the `_adapter_for_source` call sites in this sibling. Intake policy sites take `_intake_adapter_for`; every send/edit/typing/pending-slot site takes `_delivery_adapter_for`. Part of #88715 (phase 4).
- gateway/run_agent_cache.py::_rehydrate_session_model_override re-resolved api_mode from the
target model but kept the persisted base_url verbatim, so a /v1-stripped or other-family
relay URL written by an older build stayed on a chat_completions model. Run
normalize_opencode_base_url after re-resolution for opencode-family providers (test drives
the production rehydration entry: red before, green after).
- hermes_cli/model_switch.py::_build_switch_result now uses model_derived_api_mode() instead
of its own exact-key table lookup, so a custom provider extending a family slug
(opencode-go-bridge) gets the same per-model wire on live /model as on resume.
- tui_gateway/server.py::_resolve_agent_model_runtime: the re-derivation guard is
any(overrides.values()) — a model_override dict always carries the three keys, so
'if overrides' was always true.
Part of #96066
A turn's hard interrupt fans out only to `_active_children`; background
delegate_task units are detached from the parent at dispatch
(`_dispatch_background` / honor_parent_interrupt=False), so /stop left them
running to completion and their result arrived minutes later as a parked
wake. Every stop surface now also calls
`tools.async_delegation.interrupt_for_session` for the session's units:
- gateway: `_interrupt_and_clear_session` (busy /stop, /new share it — /new
already did this in `_handle_reset_command`, the second request is
idempotent) and the idle `_handle_stop_command` tail, which replied
"No active task to stop." while a background child was running; it now
stops them and replies "Stopped".
- tui_gateway `session.interrupt` (Desktop Stop / TUI stop) — own UI sid +
spawner id only, so a viewer tab never kills gateway work.
- acp_adapter `cancel`.
- CLI `/stop` already used `interrupt_all` (process-wide); unchanged.
The stop recurses: depth>0 delegations are always synchronous
(`_model_background_value`), so the child's hard interrupt reaches its
workers through its own `_active_children` fan-out, and each level's
interrupted partial result rolls up as that child's completion.
An interrupted child's entry now carries what it actually had: the loop's
`final_response` is the "Operation interrupted." placeholder (also appended
as the closing assistant row), so `_build_result_entry` takes the child's
last real assistant text as `summary` and keeps the placeholder as `error`.
The unit finalizes normally and re-enters at once as its completion notice
(status=interrupted, "Partial output: ...", "Subagent Task Interrupted" on
TUI/Desktop) instead of the chat waiting for the child's budget to run out.
Docs: delegate_task description, tools/AGENTS.md, delegation.md,
gateway-session-lifecycle.md.
Part of #114456
_interrupt_and_clear_session popped the adapter's single pending slot and dropped whatever it
held ("consume and discard", 59575d6a91). That was right when the slot only ever carried the
user's stale follow-up text, but internal wakes (async-delegation completion notices,
kanban/cron notify+wake) now park in the same slot and are claim-settled the moment the adapter
admits them, so the pop lost them for good: the drain that runs after the command found an
empty slot and the session idled until the next user message (#114456, ~6 min stall in the
reported session).
Now the human follow-up is still discarded, but an internal wake stays parked (promoted out of
the overflow FIFO when a discarded human head occupied the slot) and the post-command drain
starts it immediately. Applies to every caller of the helper — /stop (busy fast path, handler,
pending sentinel, thread sibling), /new and /reset — since a wake that arrives a second after
/new runs against the fresh session anyway; whether a completion pinned to the closed session
may run stays with _resolve_async_delegation_session (fail-closed).
Salvaged from #114538 (@whyyagswhy, earliest filer): the pop-and-re-park mechanism is theirs;
widened here to the /new and /reset callers and the overflow promotion, and the invariant tests
rewritten against the real adapter drain. #114540 (@JoaoMarcos44) reached the same fix
independently; its stop-reason taxonomy and overflow analysis informed the class coverage.
Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
The non-compression branch of _resolve_async_delegation_session awaited the
spawning-session row lookup and then unconditionally called switch_session(),
so a run invalidated (/stop) or a route replaced (/new, /resume) while the
lookup was pending was overwritten by the stale completion's pin.
- Snapshot the routing key's run generation before the await and re-check it
before mutating the route; invalidated -> drop the injection, route untouched.
- switch_session(expected_session_id=...) turns the /resume primitive into a
CAS for this caller, mirroring advance_compression_session: a route that
moved past the snapshot wins over the late completion.
- _current_session_run_generation extracted from _is_session_run_current.
Slimmer redo of #113692 (@KoNit-K) and #113716 (@wangtaotaotao95): same
direction, without the second routing-authority lock and authorize callbacks.
Fixes#113690
Co-authored-by: KoNit-K <konit.block@protonmail.com>
Co-authored-by: wangtaotaotao95 <wangtaotaotao95@users.noreply.github.com>
* test(local-runtime): pin resume to the live managed llama.cpp port
A session that stored last boot's loopback URL must not keep the client
on a dead ephemeral port after the supervisor moves.
* fix(local-runtime): follow the live managed llama.cpp port on resume
Sessions persist last boot's loopback URL, so a supervisor port change
left the client on a dead endpoint. Drop that snapshot for llamacpp
and keep the live supervisor URL.
* fix(tui_gateway): drop the llamacpp snapshot URL at one seam
_resolve_agent_model_runtime already discards a persisted base_url when the
resolution came from the local runtime; the second blank in
_stored_session_runtime_overrides (wrapped in a try/except around an import
and a string compare) duplicated it.
* fix(cli): keep a launch-time --base-url on a same-provider llamacpp resume
Re-resolving the managed endpoint is for the snapshot URL a session persisted;
an explicit --base-url for the provider the session already ran on is user
intent and stays in charge. Also hand target_model to the resolver like the
provider-changed branch does.
* fix(gateway): rehydrated llamacpp overrides follow the live managed port
Same bug class as the CLI and TUI resume paths: after a gateway restart the
persisted /model override kept last boot's loopback URL over the freshly
resolved managed endpoint, so a supervisor that came back on an ephemeral port
(18434 busy) left the session on connection errors.
* docs(local-runtime): port-fallback warning no longer asks for a model re-pick
---------
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
Closes the remaining atoms of #112600.
A) The CLI startup path already passes `-m` as target_model (c358a6fba0), but
its siblings still resolved credentials against config's `default`: the CLI
auth-fallback rung, `--resume` credential re-resolution, the gateway
provider-override helper (channel overrides, persisted /model switches,
API-server provider refresh), the gateway fallback chain, the TUI /model
switch-from runtime and ACP agent construction. With a `*-free` default the
OpenCode free-tier rung fired first and a Go-only model was built against
the keyless Zen relay ("Model mimo-v2.5 is not supported"). Each now passes
the effective model; `_resolve_runtime_agent_kwargs_for_provider` grows an
optional `target_model` and the two test stubs of it accept the kwarg.
B) normalize_opencode_base_url rewrote the /zen vs /zen/go segment for ANY
provider matched by opencode_provider_family, including custom providers
merely named after a family (`opencode-go-bridge`, #85589) whose relay the
user declared explicitly in `providers:`. The family heal now applies to the
built-in canonical providers only; custom prefix-named providers keep their
per-model api_mode routing and /v1 handling. Documented in the providers
guide.
C) Same function: the official-host check uses parsed.hostname (a port no
longer defeats the heal) and only the path is edited, so query/fragment
round-trip instead of being dropped.
Fixes#112600
The parent commit only stamped a tool_reason on the watchdog producers, so a
reason-less hard_cancel still minted "explicit stop requested" (a USER reason)
for gateway stop/restart, session eviction while a turn runs, abandoned SSE
runs and TUI/desk shutdown - the exact confusion class of #112647, failing open
toward "user". Pass a system tool_reason at those producers (run_shutdown,
run_agent_cache via the eviction caller only - /stop and /new stay user-owned -
and api_server) so only genuine surface stops book interrupted_by_user.
InterruptScope.cancel now takes tool_reason so the Hermes Console user-cancel
branch is attributed to the user; the console timeout keeps the host slug.
Also repairs the red CI: the cross-process lease tests stub agent.interrupt
with (message, hard_cancel) only, so the new tool_reason kwarg raised TypeError
inside the lease's fallback and the stub never fired. Widen the doubles, and
update the two shutdown mocks that asserted the exact interrupt() call.
`commit_memory_session` also calls the context engine's `on_session_end`,
and that engine runs without an external memory provider. The boundary
evict gated on `_memory_manager` alone, so a context-engine-only agent lost
its reset and compress notification. Resource evicts keep the provider-only
gate: the session may resume, so the engine's session stays open.
/new and /compress evicted the cached agent through the resource-only soft
release, so memory providers never received on_session_end for the
conversation that just ended; main's CLI ran commit_session_boundary_async
at the same point. Route the two boundary mutations through the cache
sweep's commit-then-release path. /model and rewind keep the plain evict.