27 Commits
Author SHA1 Message Date
Michael Snoyman fdd72e815a Fix gateway approval settlement lifecycle
Settle removed approval producers before fallible projection work, and fail-close every producer owned by a terminal gateway run across completion, failure, cancellation, and teardown while preserving unrelated runs.
2026-09-22 06:29:27 +00:00
59023b784c Release — approval no-run mirror masking fix (#7086) (#7125)
* fix(approval): stamp mirror token when creating no-run legacy mirror (#6008 follow-up)

In in-process (legacy) mode, a guarded command parks an _ApprovalEntry in
_gateway_queues whose .data has no run_id. submit_gateway_pending_mirror()
mirrors it into _pending for the UI in the `elif not exact_local_entry`
branch, but that branch never stamped _GATEWAY_MIRROR_TOKEN on the mirror.

_resolve_approval_legacy() binds a no-run mirror back to the parked
_ApprovalEntry via a token match (pending_token == gateway_token). Without
the mirror token the match fails, resolve_gateway_pending_local() is never
called and entry.event.set() never fires, so the agent thread stays blocked
even though the endpoint returns ok:true and the UI closes the card. Only a
~1.5s reconcile fallback re-creates the card with a token -> the FIRST click
on "Allow once" silently does nothing.

Stamp the token from the live gateway head at mirror-creation time, matching
the exact-local branch, so the first click unblocks the agent.

Adds a regression test reproducing the scenario.

* fix(approval): resolve non-head no-run mirrors by scanning all live producers

The first-click fix stamps the mirror token from the mirror's own producer.
But _resolve_approval_legacy() previously matched that token only against the
queue head (`_gateway_queues[sid][0]`), so a non-head mirror (multiple parked
producers, #7093) could not be resolved: the head's token differs from the
selected producer's, the match failed, and the agent thread stayed blocked.

Scan every live producer in _gateway_queues for a matching
_webui_mirror_token or approval_id, and stamp the resolved producer's
approval_id before delegating to resolve_gateway_pending_local(). Registered
producers keep head/tail ordering, so exact-producer isolation is preserved.

Adds tests/test_issue_legacy_first_click_repro.py::test_legacy_non_head_
producer_unblocks_only_its_producer, which reproduces the non-head scenario.

* fix(approval): fail closed on no-run mirror token binding (818fd2fd follow-up)

submit_gateway_pending_mirror() previously fell back to "first unclaimed
token" or the queue head when a no-run mirror's identity (request_id/
approval_id) didn't match any live producer. That let approving a
stale/foreign mirror silently resolve a different, unrelated live
producer than the one the user actually saw.

Bind the token only on an explicit identity match; otherwise leave the
mirror tokenless so it can't borrow another producer's slot. A tokenless
orphan is still created automatically for the legitimate #7093 case
(no live producer at all) via reconcile_gateway_pending_mirror_locked.

Adds two adversarial regression tests (stale request_id vs. a live
sibling, and an identity-less mirror with multiple producers parked) and
fixes an unrelated test that omitted request_id, unlike every real
_ApprovalEntry.

* fix(approval): don't let an unmatched no-run mirror mask a live producer

Follow-up to 63087a40. Failing closed on token binding stopped stale
mirror A from executing live producer B, but left A parked in _pending:
reconcile then deferred to the pre-existing no-run mirror and skipped
tokenizing B, so B never surfaced as the head. The real pending approval
became unactionable (409 in gateway mode, ok:true resolving nothing in
local mode) while an unresolvable card sat in front of it.

reconcile_gateway_pending_mirror_locked() now always tokenizes every live
no-run producer and derives live_token from the authoritative head
whenever _gateway_queues holds producers, so an unmatched tokenless
mirror is discarded instead of suppressing the real one. Retained
missing-producer mirrors survive only while no producer is live, keeping
the #7093 tokenless-orphan boundary intact.

Tests assert the displayed == resolved invariant: the authoritative
producer is the head, responding with the stale/identity-less id fails
without waking anything, and approving the displayed entry wakes exactly
its own producer. Three test_gateway_approval_runs_api.py cases that
encoded the old masking behavior are updated to the new invariant.

* docs(approval): correct reconcile comment on which mirrors survive

The comment added in 9c2ca104 claimed only the authoritative head's mirror
may survive while a producer is live. That contradicts the code it
documents and the non-head regression test: the `entry_token in
live_local_tokens` branch keeps a mirror bound to ANY live no-run
producer, which is exactly what makes a non-head mirror resolvable
(test_legacy_non_head_producer_unblocks_only_its_producer).

State the actual rule instead — a mirror survives only if bound to some
live producer's own token, the head's via live_token or a non-head
producer's via live_local_tokens — so unmatched and tokenless copies are
discarded without implying non-head mirrors are dropped too. Comment
only; no behavior change.

* Release — approval no-run mirror masking fix (#7086)

---------

Co-authored-by: VHSgunzo <wosganzo@gmail.com>
Co-authored-by: n <a@n>
2026-08-18 13:11:07 +00:00
Rod Boev 8119d91dc3 fix(#6008): serialize identity-less approval relays 2026-07-22 15:06:33 -04:00
Rod Boev dc4078426a fix(#6008): retire failed gateway startup lifecycle 2026-07-22 12:33:57 -04:00
Rod Boev 5a954c79d3 fix(#6008): keep gateway cancel authority stable across startup teardown 2026-07-22 12:33:26 -04:00
Rod Boev 2c6ce96596 fix(#6008): wait for gateway stop acknowledgement 2026-07-22 12:33:26 -04:00
Rod Boev 4a71e407b1 fix(gateway): keep retained no-run approvals visible 2026-07-22 12:31:12 -04:00
Rod Boev 0a3a630856 fix(gateway): keep approval and stop acknowledgements honest 2026-07-22 12:31:12 -04:00
Rod Boev ecdd0af573 test(gateway): cover capability-gated approval IDs 2026-07-22 12:31:12 -04:00
Rod Boev 918b9ddb63 test(gateway): align approval lifecycle regressions 2026-07-22 12:31:12 -04:00
Rod Boev fb88095530 fix(gateway): require agent-issued approval identities 2026-07-22 12:31:12 -04:00
Rod Boev a91bd88c70 fix(gateway): preserve approval identity provenance 2026-07-22 12:31:12 -04:00
Rod Boev f30178e18a fix(gateway): fall back safely without approval identities 2026-07-22 12:31:12 -04:00
Rod Boev b481dfea13 fix(#6008): keep retained approvals visible in session attention 2026-07-22 12:31:12 -04:00
Rod Boev 6460c48ce6 fix(#6008): keep gateway approvals attached to the right run 2026-07-22 12:31:12 -04:00
Rod Boev b907630f8f fix(#6008): keep gateway approvals attached to the right run 2026-07-22 12:31:11 -04:00
Rod Boev 489728d71f fix(gateway): scope approval mirror cleanup to live heads (#6008) 2026-07-22 12:19:33 -04:00
Rod Boev 39fc169834 fix(#6008): route gateway approvals by run ID 2026-07-22 12:19:33 -04:00
Rod Boev 234fefe6ee fix(#5139): keep timeout capability probes on the unsupported path 2026-06-28 18:29:37 -04:00
Rod Boev 23b7a7935a fix(#5139): keep older gateways on the unsupported warning path 2026-06-28 18:18:47 -04:00
Rod Boev 343a8b50bf fix(#5139): distinguish offline gateway approval warnings 2026-06-28 17:53:37 -04:00
Rod Boev ee588ba70e fix(#4550): harden gateway reasoning forwarding follow-up 2026-06-20 18:44:25 +00:00
Rod Boev 26c55404a5 fix(#4550): forward gateway reasoning effort 2026-06-20 18:44:25 +00:00
Rod Boev 6ae0ecca0a fix(#4535): include session_id in Runs API request body 2026-06-20 17:42:18 +00:00
Rod Boev c6392902cd fix(gateway): correct approval URL and thread session history into runs API (#4479) 2026-06-19 13:16:39 -04:00
nesquena-hermesandRod Boev 84d3da7230 fix(#4362): gate gateway runs-API routing behind explicit opt-in
The WebUI gateway chat backend routed every browser chat turn through the
gateway's /v1/runs API whenever the gateway advertised approval/runs
support (gateway_chat.py capability gate). The runs API creates an isolated
Api_Server session per turn, so mid-conversation context was lost between
messages. Routing through /v1/runs is now gated behind an explicit opt-in
(_gateway_use_runs_api_enabled: HERMES_WEBUI_GATEWAY_USE_RUNS_API env var or
webui_gateway_use_runs_api config key, env-over-config precedence, default
off). The capability gate now requires BOTH the opt-in flag AND gateway
approval support before using /v1/runs; otherwise normal chat stays on
/v1/chat/completions, preserving the session via X-Hermes-Session-Id.
Operators who want the in-gateway-chat approval-events flow re-enable it
with the flag.

Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-17 17:41:45 +00:00
ecf637c66d Release PN (v0.51.453): bridge approval controls to the gateway runs API (#4229, #4203) (#4299)
Approve/deny on a remote-gateway backend now forwards to the gateway runs API
(run_id server-derived, existing auth+CSRF preserved, capability-probed + 60s
cached). Non-gateway/local approval path unchanged — relay only fires when a
session has an active gateway run (_STREAM_RUN_IDS). Old gateways get a clear
upgrade message instead of a silent no-op.

Co-authored-by: rodboev

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-06-15 22:25:24 -07:00