Codex round-3: a look-alike chat DB carrying the seven indexed column names was indexed. Every Agent state.db since the first schema (440c244cac) has a schema_version row and sessions keyed on id; require both before any CREATE INDEX. Test oracles now ignore sqlite_autoindex_* (TEXT PRIMARY KEY).
Codex delta gate: same-named messages/sessions tables with foreign columns
exited 0 with every index 'skipped', and a same-named incompatible index on
the legacy gap was skipped past its validation. Now: validate every keyed
column before creating anything; only sessions.last_activity_at may be
absent (reported 'skipped'); a same-named index on that gap is
'Incompatible index'. Regressions for both shapes.
A missing *column* is an older agent schema (skip that index); a missing
*table* means the path is not an agent state.db. Without this the new
skip path reported every index 'skipped' with exit 0 on an unrelated DB.
On a pre-2026-07-28 Agent schema, sessions has no last_activity_at, so the
fingerprint index's CREATE failed inside the single BEGIN IMMEDIATE and rolled
back idx_messages_session too, leaving messages unindexed: the exact older-agent
case the troubleshooting doc says the tool fixes. Gate each index on its columns
being present and report 'skipped'. (Opus advisor SHOULD-FIX on #7445.)
state_db_file_uri() quoted the POSIX path as a str; an undecodable filename
byte is carried as a surrogateescape code point, which quote() rejects with
UnicodeEncodeError, and get_cli_sessions() swallowed that as an empty
session list. Quote os.fsencode() bytes instead (what Path.as_uri() did on
master). Regression test: a b'\xff' HERMES_HOME component opens read-only.
Maintainer fold-in during release gating (Codex re-gate finding).
Co-authored-by: ruizanthony <ruizanthony@users.noreply.github.com>
tests/test_issue4783_profile_skills_mtime_cache.py installs a bare
types.ModuleType stub for agent.skill_utils via sys.modules.setdefault and
never restores it. On CI (no hermes-agent checkout) that stub satisfies
pytest.importorskip, so the positive control ran against an rglob walk with
MagicMock frontmatter and failed 9 == 7 on shard 4 of 5, all Pythons.
Require a file-backed module; the control still runs (and passes) against
the real agent package.
The env override never reached /api/sessions because the route kept a
separate hard-coded cap of 20. Resolve the window in api.config before
profile init, clamp it at 200, and refuse a profile .env override.
When the user writes in CJK, the title language mismatch guard now allows
Latin characters in the title as long as the title also contains CJK
characters (cjk >= 2). This permits CJK titles with borrowed English
product/technical terms (e.g. 'WeChat Pay 回调失败排查', 'Python 代码修复')
while still rejecting pure-Latin titles for CJK conversations and
unrelated scripts (Cyrillic, Arabic, etc.).
Added regression tests: test_cjk_start_mixed_cjk_latin_title_is_allowed
and test_cjk_start_pure_latin_title_still_rejected.
A user prompt could render at the very bottom of a conversation, below the
assistant turn that had already answered it.
merge_session_messages_append_only reconciles the sidecar with state.db. When a
state.db row carries a typed row id that is unique to state.db and absent from
the sidecar (the two stores do not share an id space),
row_id_preserves_source_multiplicity is true and the row bypasses the
sidecar-timestamp-range block -- including the
_insert_state_message_chronologically call that block owns. It then fell through
to the terminal merged_messages.append and landed at the tail regardless of its
timestamp.
The full display path re-sorts by timestamp afterwards
(_merged_webui_lineage_messages_for_display), which hid the defect. The
paginated path used for the initial render (msg_limit) does not, so the user saw
their own message below its own answer until a full reload reshuffled it.
Place such a row in its chronological slot when its timestamp predates the
sidecar tail, falling back to append when no safe slot exists. Rows that are
genuinely newest still append at the tail, so the append-only contract (never
drop a local row) is preserved.
Verified on the reported transcript: the merged tail now ends on the assistant
conclusion instead of the orphaned prompt, with the same message count.
Gate fixes on top of @rodrigogs's #6658, both independently reproduced first.
do_GET called bare check_auth while every write path used check_auth_or_close,
so a body-bearing GET failing top-level auth left its bytes queued on a
keep-alive socket. check_auth_or_close is already body-pending-aware, so the
bodyless login-redirect flow keeps its keep-alive. Removing the now-unused
check_auth import (ruff F401) broke 9 tests that monkeypatch that symbol
through a module alias; rewritten to the new name.
_declared_content_lengths ran isdigit() across the whole raw header value, so
a comma-combined 'Content-Length: 0, 0' -- the same bodyless request as two
repeated headers under RFC 9110 5.3 -- read as unreadable and over-closed a
healthy connection. Now splits on commas before validating each member;
disagreeing lists still fail closed.
Verified: 16/16 adversarial CL vectors still fail closed (no fail-open
reopened), 13/13 over-close cases fixed, live-socket drive shows close armed
on a body-bearing reject and keep-alive preserved on a bodyless one, and a
chunked request behaves byte-identically to master (master never supported
chunked either, so no proxy deployment regresses).
Co-authored-by: rodrigogs <rodrigogs@users.noreply.github.com>
The mirror image of the sidecar over-close already fixed here: the sibling
reject-before-read sites still armed `arm_connection_close()` unconditionally,
so a request with NO declared body was answered with `Connection: close` and
the client's pipelined follow-up was dropped -- a healthy pooled connection
killed for no framing reason. Reproduced on the wire, pipelined down one
socket against the production handler (`responses seen: 1` at each site):
POST /api/session/new off-session auth -> 401 + Connection: close
POST /api/session/new bad Origin -> 403 + Connection: close
POST /api/session/new no CSRF token -> 403 + Connection: close
POST /api/csp-report limiter tripped -> 204 + Connection: close
POST /api/health/restart success -> 200 + Connection: close
POST /api/process-complete-ack -> 410 + Connection: close
POST /api/upload no boundary -> 400 + Connection: close
Each now routes through `arm_connection_close_if_body_pending()` (the helper
the sidecar path already used), so the framing decides in both directions:
body-bearing or unreadable framing still closes, an absent or agreeing-zero
Content-Length keeps keep-alive. `check_auth_or_close()` cannot swap the helper
in directly -- it has to arm before `check_auth()` writes its 401/302 and undo
that on success -- so it gates the arming on `request_declares_body()` instead
and keeps the success-path restore.
`/api/health/restart` is armed once, ahead of the outcome, so all four results
(completed / in_progress / busy / error) follow the framing rather than the
result; the success path was closing on every call the WebUI makes.
Only the boundary check in the upload preflight can reject a bodyless request:
every other raise there (any Transfer-Encoding, an unreadable or conflicting
length, an over-cap length) already declares a body and still closes.
Five tests pinned the unconditional close on stubs that declared no body --
i.e. they pinned the bug. Each is re-grounded on real body-bearing framing
(Content-Length, chunked, and an unreadable length) so the close half stays
pinned, with a bodyless sibling asserting keep-alive. New real-socket
regressions cover all seven sites in both directions.
`identity` was exempt as a coding that "frames nothing", so a request
carrying it read back as body-less however many payload bytes followed.
It frames nothing either: RFC 9112 6.3 says a request whose final
transfer coding is not `chunked` has a body length no recipient can
determine, RFC 7230 removed `identity` from the transfer codings
outright, and `http.server` decodes none of them (`rfile` is the raw
socket). Every present value has to reject-and-close.
Reproduced on 8c326003b, pipelined down one socket:
GET /api/extensions/probe/sidecar/ping `Transfer-Encoding: identity`
{"stale": true}
-> HTTP/1.1 403 Forbidden (no Connection: close)
-> HTTP/1.1 400 Bad request syntax
('{"stale": true}GET /api/health/agent HTTP/1.1')
POST /api/upload (and /upload/extract, /workspace/upload, /transcribe)
-> HTTP/1.1 400 Bad Request ("No file field in request", no close)
-> HTTP/1.1 400 Bad request syntax ('--x')
Deletes the `_UNFRAMED_TRANSFER_CODINGS` allowlist and the fold-safety
test that existed only to guard its membership check; `_BLANK_TRANSFER_CODING`
stays, since a present-but-empty header still needs a named, non-falsy
report. The identity rows of the framing tables and of
`test_bodyless_framing_keeps_the_pooled_socket_alive` move to the
closes-the-socket side, with real-socket coverage on the sidecar path and
all four upload endpoints. The keep-alive half stays pinned by the cases
that genuinely have no body: no framing header at all, `Content-Length: 0`,
an OWS-padded zero, two agreeing zeroes.
Swept the rest of the coding surface for the same class: every case
spelling, OWS and non-OWS padding, `identity, chunked`, `chunked, identity`,
`identity,identity`, a lone comma, an unknown coding, duplicated headers,
and a coding beside both a real and a zero Content-Length.
`_declared_content_lengths()` parsed with `int()`, which is far looser than RFC
9110's `Content-Length = 1*DIGIT`: it accepts a leading sign, PEP 515
underscores and non-ASCII digits, and `str.strip()` ahead of it also erased
non-ASCII whitespace. Every such spelling of ZERO therefore parsed to an honest
`0`, took the "every declared length agrees on zero" keep-alive branch, and left
the payload in rfile for the server to parse as the next request line -- the
same defect the blank value had, one layer down. Reproduced live at the previous
head, pipelined down one socket:
GET /api/extensions/probe/sidecar/ping `Content-Length: +0` {"stale": true}
-> HTTP/1.1 403 Forbidden (no Connection: close)
-> HTTP/1.1 400 Bad request syntax
('{"stale": true}GET /api/health/agent HTTP/1.1')
byte-for-byte the trace greptile filed the P1 for, and identical for `-0`,
`0_0`, `+00`, `0_0_0`, `+0_0`, `\xa00`, `0\xa0` and `\x850`. On /api/upload the
same values slipped `unreadable_content_length()` into `int('0_0')` == 0 and
answered 400 keep-alive then `400 Bad request syntax ('--x')`.
A sign was reported safe last round because it was probed as `+15`, where a
wrong parse closes anyway; only the zero-valued spellings expose it.
The parse is now the grammar: an unsigned run of ASCII digits, or the framing is
unreadable and a body is assumed pending. The `int()` guard stays -- a digit run
longer than `sys.get_int_max_str_digits()` (4300) raises ValueError too, and a
raise on a rejection path becomes a spurious 500.
`Transfer-Encoding` gets the same audit. Trimming is now SP/HTAB only, RFC 9110
OWS and nothing else, because `str.strip()` erased U+00A0/U+0085 there too:
`Transfer-Encoding: \xa0identity` -- a spelling no token grammar allows -- read
back as the `identity` token, so it counted as unframed and poisoned the socket
with the same 403-then-400 trace (411 and close on uploads). Both characters
reach the wire, since a request line decodes as latin-1. Membership in
`_UNFRAMED_TRANSFER_CODINGS` is tested through `str.lower()`, which maps some
non-ASCII characters ONTO ASCII ones (U+212A -> `k`), so the set is now
documented as ASCII-token-only and a test scans every codepoint to prove no
non-ASCII spelling can reach it.
Non-ASCII digits are unreadable by the same rule and pinned at the helper level:
U+0660/U+FF10 cannot cross an HTTP/1.1 header line (latin-1 leaves only the
U+00B2/B3/B9 superscripts, which `int()` already rejects), but the helpers take
any headers mapping and enforce the grammar rather than trusting the transport.
The no-body contract is unchanged and pinned in both directions on the wire and
at the helper: no length header, `0`, `00`, an OWS-padded zero, two AGREEING
zeroes, and `identity` in any ASCII case still keep the pooled connection alive.
`Content-Length:` with nothing after the colon reads back as '' from
`Message.get_all()`, and `_declared_content_lengths()` SKIPPED the empty value.
The result was "no length declared", so `request_declares_body()` reported no
pending body and the sidecar provenance rejection answered 403 with keep-alive
intact -- leaving the payload in rfile for the server to parse as the next
request line. Reproduced live, pipelined down one socket:
GET /api/extensions/probe/sidecar/ping `Content-Length:` {"stale": true}
-> HTTP/1.1 403 Forbidden (no Connection: close)
-> HTTP/1.1 400 Bad request syntax
('{"stale": true}GET /api/health/agent HTTP/1.1')
A blank value cannot prove a body's absence, so it now joins `banana` and two
disagreeing lengths as unreadable framing: bytes are assumed pending and the
connection closes. Absence is something only the wire can say, and an absent
header already yields no values at all.
Three adjacent holes of the same class were found by probing and are fixed by
the same rule (each reproduced live with the same bad-syntax 400):
* whitespace-only `Content-Length: ` -- `Message` collapses it to the same '',
as it does a duplicated blank or a blank paired with a real length.
* the four multipart upload handlers: a blank length slipped past
`unreadable_content_length()`, reached `int('' or 0)` == 0, and answered 400
"No file field in request" with the payload still queued. Now 400 "Invalid
Content-Length" with the connection closed.
* blank `Transfer-Encoding:` -- '' sat in `_UNFRAMED_TRANSFER_CODINGS` beside
`identity`, so a header naming no coding read as "nothing to frame". A blank
coding is undecodable, not unframed; uploads answer 411 and close.
The no-body contract is unchanged and pinned in both directions: no length
header, a padded zero, two AGREEING zeroes and `identity` all still keep the
pooled connection alive.
Adversarially probing the re-gated head found the same poisoning reachable by
DUPLICATING a framing header instead of omitting one. `Message.get()` returns
only the first occurrence, so `Content-Length: 0` sent ahead of the real length
made the body invisible to every reader: nothing was drained and the real bytes
were parsed as the next request line. Reproduced live on the otherwise-fixed
head, over one pooled socket:
HTTP/1.1 403 Forbidden <- sidecar provenance reject
HTTP/1.1 400 Bad request syntax ('{"stale": true}GET /api/health/agent ...')
HTTP/1.1 400 Bad Request <- /api/upload reject
HTTP/1.1 400 Bad request syntax ('--x')
api/helpers.py now reads framing through `_framing_header_values()`, which
returns every value the wire carried. `request_declares_body()` treats
disagreeing or unparseable lengths as a declared body (they cannot prove the
body's absence), and `unsupported_transfer_encoding()` inspects every declared
coding so `identity` cannot mask a `chunked` behind it. Two agreeing zeroes are
still no body -- repetition alone is not a conflict.
New `unreadable_content_length()` gates the multipart preflight: with no honest
byte count there is nothing to drain, so all four handlers refuse with 400
"Invalid Content-Length" before touching rfile, connection armed for close.
13 regressions, each verified red without this commit: the helper predicates on
a real email.message.Message, the production sidecar 403, all four multipart
handlers, and five pipelined real-socket tests proving the follow-up request is
never answered off a poisoned socket.
Two framing gaps the re-gate reproduced on the production handlers, both the
same class: something other than the wire was used to decide whether unread
body bytes are queued.
1. api/routes.py — the sidecar provenance 403 gated its close on
`read_request_body`, a static per-method flag. That is wrong in both
directions: a GET carrying `Content-Length`/`Transfer-Encoding` was answered
with keep-alive and its body then parsed as the next request line
(`501 Unsupported method ('{}GET')`), while a body-less DELETE/PUT/PATCH had
a healthy pooled connection closed for nothing.
2. api/upload.py — all four multipart handlers read framing from
`Content-Length` alone, so a chunked upload (which has none) was treated as
length zero, matched no parts and was rejected 400 "No file field in
request" with keep-alive intact. Wire trace before the fix:
HTTP/1.1 400 Bad Request <- the upload rejection
HTTP/1.1 400 Bad request syntax ('54') <- our own chunk-size line
New primitive in api/helpers.py: `request_declares_body()` (Content-Length
present and non-zero, or any Transfer-Encoding other than identity; an
unparseable length counts as present, since it cannot prove absence) plus
`arm_connection_close_if_body_pending()` and `unsupported_transfer_encoding()`.
api/upload.py now routes all four handlers through one preflight,
`_read_multipart_or_reject()`, which refuses an undecodable transfer coding
with 411 before touching rfile — http.server never decodes chunked framing, so
that request can only be rejected — and keeps every previous status and message
(413 oversize, 400 invalid Content-Length/boundary) with its close.
Regressions: chunked reject that closes and a body-less reject that does not,
at all four upload handlers and at the sidecar 403, plus two pipelined
real-socket tests against the running server proving the follow-up request is
never answered off a poisoned socket.
advertise_connection_close() guarded its two attribute reads but not the
dedup scan over _headers_buffer. A stub whose buffer is truthy and
non-iterable (a bare Mock) raised TypeError from inside end_headers(),
which _handle_write's generic except would swallow into a spurious 500 --
the same failure class as the missing close_connection guard.
The first cut of the close advertisement had two defects, both caught by CI
(9 red jobs) and by the deep-review gate's own MUST-FIX 1 wording.
1. Dedup was keyed off a private `_close_advertised` flag that end_headers()
set itself. Every SSE endpoint already sends its own `Connection: close`,
and BaseHTTPRequestHandler.send_header flips close_connection when it does
— so end_headers() appended a SECOND copy to every stream response.
Dedup now derives from the pending header buffer, which sees those
site-level headers too.
2. `self.close_connection` was read unguarded. The attribute only exists once
a request line has been parsed, so the many suite handlers built with
`Handler.__new__(Handler)` raised AttributeError instead of emitting
headers — and in _handle_write that AttributeError became a spurious 500.
Both reads are now defensive, and check_auth_or_close() no longer invents
the attribute on a handler that never had it (which would have left a bogus
keep-alive verdict behind).
The mechanism moves to api/helpers.advertise_connection_close(), which also
puts server.py back under the original 750-line guard, so the test's ceiling
bump to 760 is reverted rather than shipped.
Deep-review gate (2026-08-14) findings, all reproduced:
1. close_connection was never serialized: BaseHTTPRequestHandler does not
emit Connection: close from the flag, so pooled clients kept reusing the
socket and died with BrokenPipeError. Handler.end_headers() now emits
the header (deduped) whenever close_connection is armed.
2. check_auth_or_close armed the flag too late (after the 401/302 was
already flushed); it now arms before check_auth() and restores the
prior flag on success.
3. The sidecar provenance rejection closed body-less GETs too; the close
is now gated on read_request_body=True.
4. Remaining reject-before-read sites now close-and-advertise through the
same mechanism: rate-limited CSP reports (204), the deprecated
/api/process-complete-ack (410), /api/health/restart (all outcomes),
and the four upload handlers (413 + pre-read Content-Length/boundary
validation via _reject_before_read).
Tests: pooled-client regression against the production handler (reject
with a body, then follow-up on the same pooled connection succeeds and the
rejection carries Connection: close); unit coverage for the sidecar GET
keep-alive, CSP rate-limit, health/restart and upload oversize paths. The
server.py line guard is bumped to 760 with rationale: the deep-review
requires the emission in Handler.end_headers(), which cannot live outside
server.py.
Same class as the auth/CSRF close-on-reject: the sidecar proxy's
same-origin provenance check runs before read_body(), so an HTTP/1.1
connection reused after a 403 would parse the unread body as the next
request line. Flag close_connection on this path too.
Found by Greptile review of #6658; covered by a new unit test.
Move the close-on-reject decision into api.auth.check_auth_or_close so the
added lines land outside server.py, restoring it to 749 lines. Trim the
inline comment to a one-line docstring on the helper, per review feedback.
The rejected-write connection-close test now patches api.auth.check_auth
(the dependency the helper calls) instead of server.check_auth.
The unit tests in this PR assert `close_connection` is set on each rejection path.
That pins the mechanism but not the consequence: they build a SimpleNamespace,
never open a socket, and would still pass if BaseHTTPRequestHandler stopped
honouring the flag.
This sends a rejected write and then an ordinary GET down one connection. Reusing
it makes the server read the unread body as a request line and answer
HTTP/1.1 400 Bad request syntax ('{"a": "b"}GET /api/health HTTP/1.1')
an error about a request the client never sent, which is what makes this class of
bug expensive to diagnose. Both directions are pinned: the reusing handler must
produce that 400, the closing handler must not.
The two files are complementary. Reverting server.py fails the unit test; making
the handler here reuse the connection fails this one.
Gate follow-up for #7710. Three front-end handlers treated EVERY 409 as a
domain-specific stale/expired signal, so the new session_profile_mismatch
response would have:
- messages.js: hidden a LIVE clarify card and mislabelled it expired
- ui.js: retired a LIVE compression-recovery card with a false note
- commands.js: rendered a false 'Compression failed' error state
Each now routes through _sessionProfileMismatchFromError() first and leaves
the card standing. Also captures the status in the import_cli test mock so a
wrong status can no longer pass (the mock swallowed it into *_).
## Thinking Path
The detail-load endpoint (and the foreign-session synthesizer) already
distinguish "session owned by a KNOWN other profile" (return ``409
session_profile_mismatch`` so the client can offer to switch to it
per #5419) from "session missing/legacy with no profile stamped"
(return ``404 Session not found`` so the frontend's self-heal
clears the stale URL + localStorage).
The generic request-guard
``_session_id_visible_to_request_profile`` flattened both cases to a
``404 Session not found``, so any cross-profile POST/PATCH/DELETE
(archive, rename, pin, delete, session-attach, anchor-scene, …) and
the stream-related status/cancel/replay paths failed with a
misleading "Session not found" instead of the actionable 409 the
detail endpoint emits. The reporter's wire evidence:
```
POST /api/session/archive {"session_id":"<id>","archived":true}
-> 404 {"error": "Session not found"}
GET /api/session?session_id=<same id>
-> 409 {"error": "Session belongs to a different profile",
"code": "session_profile_mismatch", "session_id": "<id>", "profile": "def"}
```
Same session id, same moment: the detail endpoint reports a profile
mismatch, archive masks it as 404.
The fix mirrors the detail endpoint's contract in the generic guard
and in every other inline check that previously went to
``bad(handler, "Session not found", 404)`` for a known-other-profile
session.
## What Changed
1. ``api/routes.py:595`` — ``_session_id_visible_to_request_profile``
now emits ``409 session_profile_mismatch`` for a session owned by
a known other profile, and keeps ``404 Session not found`` only
for the None-profile (unknown/legacy) case so the frontend
self-heal still fires for actually-missing sids.
2. ``api/routes.py:5348`` (anchor-scene persist), ``:23994``
(compression-recovery start), ``:24385`` (chat/start with
the inline placeholder-retag rules preserved), ``:28621``
(session import_cli's already-imported branch) — the same
409-vs-404 split, with the existing branches kept intact (the
chat/start retag path, the import_cli ``allow_all_profiles`` short
circuit).
3. The ``_stream_id_visible_to_request_profile`` wrapper already
delegates to ``_session_id_visible_to_request_profile`` for the
owner-session check, so the stream status / cancel / replay
endpoints get the new contract for free.
## Why It Matters
A user picking the configured default model in the dropdown sees two
visually identical entries for the same model id, picks the wrong
"prefixed" one (the one matching the dropdown's order), the agent
returns 400, and the session state is corrupted with a non-functional
model id until the user manually clears it. The dropdown's own
deduplication already handles `@provider:model` correctly via the
documented prefix form; only the synthesised ``{provider}/{model}``
form was wrong.
The cross-profile write paths (anchor-scene persist,
compression-recovery start, chat/start, session import) all had the
same gap: a successful auth under profile A could persist or recover
state against a session owned by profile B, surfacing a 404 to
profile A's user that reads like "the session is gone" — but the
session is in fact intact, just owned by another profile. The 409
contract is what closes the gap: the client gets a structured signal
it can act on, and the session state stays consistent.
## Verification
- ``tests/test_issue7710_cross_profile_404_to_409.py`` (new, 6 tests):
- 5 behavioural: profile-mismatch path returns 409 with the
``session_profile_mismatch`` payload, the None-profile path
keeps 404 for the frontend self-heal, visible session passes
silently, missing session passes silently, ``emit_error=False``
suppresses the response.
- 1 source-shape: the helper body MUST contain a 409 branch with
the ``session_profile_mismatch`` code; a silent reversion to
the single-line ``bad(..., 404)`` fails the suite.
- ``tests/test_issue4812_session_sse_stream.py`` — 1 assertion
updated to the new 409 payload (the SSE route walks through the
generic guard).
- ``tests/test_session_active_profile_authorization.py`` — 7
assertions updated to the 409 payload (duplicate, file, chat/start
foreign, chat/stream/status, chat/stream/cancel, chat/stream
replay). Stream tests use ``foreign_session`` (the owner session
id), NOT the stream id, because ``_stream_id_visible_to_request_profile``
delegates to the owner-session check.
- ``tests/test_anchor_scene_persistence.py`` — 1 assertion updated
to the 409 payload. The 3 ``Assistant message not found`` 404
cases (lines 454/561/611) are kept as-is — they're not profile
mismatches, they're missing-message 404s.
- ``tests/test_issue4067_import_cli_cross_profile_guard.py`` —
1 assertion updated to the 409 payload, plus the ``_capture`` mock
signature updated to accept the ``status`` keyword that
``j(handler, payload, status=200, ...)`` already passes.
Other tests in the existing suite: **161 passed across 8 related
test files** (session active-profile authorization, anchor scene,
issue 4067, issue 4812 SSE, issue 7710 new, chat start claim,
workspace stale recovery, issue 5420 profile switch).
## Contract Change
The following routes now return ``409 session_profile_mismatch``
(``{error, code, session_id, profile}``) for a session owned by a
known other profile, where they previously returned
``404 Session not found``:
- ``POST /api/session/duplicate`` (now also goes through
``_guard_request_session_visibility`` for the body session id)
- ``GET /api/file?session_id=…``
- ``POST /api/chat/start`` (the inline profile check after
``_get_or_materialize_session``)
- ``GET /api/chat/stream/status?stream_id=…``
- ``GET /api/chat/stream/cancel?stream_id=…``
- ``GET /api/chat/stream?stream_id=…`` (the dead-stream replay
branch)
- ``POST /api/session/anchor-scene``
- ``POST /api/session/compression-recovery/start``
- ``POST /api/session/import_cli`` (the already-imported branch
when ``allow_all_profiles`` is False)
The ``404 Session not found`` self-heal path is preserved for the
None-profile (unknown/legacy) case across all of these routes, so
the frontend's existing stale-URL/localStorage cleanup behaviour is
unchanged for actually-missing sessions.
## Risks
- A consumer that previously relied on the 404 to detect "this
session belongs to another profile" will now see a 409 with a
``code`` field. The detail-load endpoint has emitted this exact
contract since #5419, and the frontend already handles it
(offers to switch to the owning profile). The cross-profile write
paths now match the detail-load contract; the frontend's
already-shipped switcher UI handles both surfaces consistently.
- The anchor-scene / compression-recovery / chat-start tests that
were updated are a small set (5 total); the existing
``Assistant message not found`` 404 paths in
``test_anchor_scene_persistence`` are kept untouched (those are
not profile mismatches).
## Related
- #7710 — original issue (elkpvl-commits, 2026-09-22) with the
full repro and the suggested fix shape.
- #7549 — the underlying cross-profile archive bug, already
tracked by @webtecnica in #7559 (which is the "business-level"
fix: row-profile carry + frontend sends profile + materialize
into the row's own profile). This PR is the orthogonal
error-code-level fix: the request-guard's 404-vs-409 split.
- #5419 — the original auto-switch-profiles-for-session-links
feature, whose frontend switcher UI consumes the
``session_profile_mismatch`` code on the 409.
## Thinking Path
When the active provider is a built-in plugin provider (e.g. ``commandcode``)
and the configured default model id contains a slash (e.g.
``deepseek/deepseek-v4-flash``), ``_build_configured_model_badges`` and
``_configured_model_badges_from_static_catalog`` used to publish three
raw_candidates for the same configured identity:
- the bare native id (``deepseek/deepseek-v4-flash``)
- a slash-prefixed alias (``commandcode/deepseek/deepseek-v4-flash``)
- the ``@provider:model``-prefixed form (``@commandcode:deepseek/deepseek-v4-flash``)
The slash-prefixed alias was synthetic — the catalog never actually
exposed that key. The WebUI dropdown rendered it as a real option,
selecting it sent ``model=commandcode/deepseek/deepseek-v4-flash`` to
the agent, and the provider rejected it with ``HTTP 400: Model ...
is not supported on this endpoint``. The session also persisted the
wrong id, so reload replayed the failure.
Verified by reading the live source at master: the catalog exposes
only the bare native id for built-in plugin providers; the
slash-prefixed alias was a three-line entry in the ``raw_candidates``
tuple. Maintainer's 9/25 pickup contract on the issue explicitly
prohibits positional slash-splitting for ownership inference: slashes
are valid in model ids (multi-slash, URI-scheme, host:port). The fix
drops only the synthetic prefix form; the bare native id (the actual
catalog form) and the documented ``@provider:model`` prefix stay.
## What Changed
1. ``api/config.py:8617`` (``_build_configured_model_badges``) — drop
the ``f"{provider}/{model}"`` entry from the ``raw_candidates``
tuple. The tuple now contains only ``model`` and
``f"@{provider}:{model}"``. Also type-annotate the local list.
2. ``api/config.py:6946`` (``_configured_model_badges_from_static_catalog``)
— same one-line change for the static-catalog fallback path so the
two builders stay in sync.
The match_id / norm_lookup / exact_match logic downstream is unchanged
— those already prefer the matching provider, and with the synthetic
alias gone the bare native id is the only remaining self-collision
candidate (which the existing ``test_duplicate_slash_id_primary_badge_sticks_to_matching_provider_only``
already pins).
## Why It Matters
A user picking the configured default model in the dropdown sees two
visually identical entries for the same model id, picks the wrong
"prefixed" one (the one matching the dropdown's order), the agent
returns 400, and the session state is corrupted with a non-functional
model id until the user manually clears it. The dropdown's own
deduplication already handles `@provider:model` correctly via the
documented prefix form; only the synthesised ``{provider}/{model}``
form was wrong.
## Verification
- ``tests/test_issue7290_no_slash_prefixed_alias.py`` (new, 10 tests):
- ``test_commandcode_deepseek_does_not_emit_slash_prefixed_alias`` —
the literal repro from the issue: ``commandcode/deepseek/deepseek-v4-flash``
MUST NOT appear in the badge map; the bare id ``deepseek/deepseek-v4-flash``
is the primary badge.
- ``test_commandcode_deepseek_at_prefixed_alias_still_present`` — the
``@provider:model`` form is preserved.
- ``test_duplicate_slash_id_does_not_pollute_other_provider`` — per
contract 4: dedupe only when provider + full native model match.
- ``test_non_slash_models_unchanged`` (parametrized × 4) — the bare,
@-prefixed, URI-scheme, and URL model id forms all keep working.
- ``test_synthetic_slash_prefix_alias_is_not_in_source`` (parametrized × 2)
— the synthesised ``f"{provider}/{model}"`` literal MUST NOT appear
in either builder; a silent reversion to the bug fails the suite.
- ``test_raw_candidates_only_holds_bare_and_at_prefix`` — pins the
shape of the ``for candidate in (...)`` tuple so a future
regression that adds any *other* synthetic prefix form
(``f"{provider}::{model}"``, etc.) also fails.
- ``tests/test_model_picker_badges.py::test_available_models_exposes_primary_and_fallback_badges``
updated to assert the bare-id form (``claude-haiku-4.5``) and the
``@provider:model`` form (``@anthropic:claude-haiku-4.5``) carry the
fallback badge; the deleted slash-prefixed form is explicitly
asserted absent. All other test_model_picker_badges cases unchanged.
- The 4 sibling tests
(``test_issue3360_multi_slash_model_collision``,
``test_issue3429_uri_scheme_model_ids``,
``test_issue3959_model_suffix_dedup``,
``test_norm_model_id_trailing_empty_guard``) still pass — they pin
``_norm_model_id`` behaviour which is unchanged by this PR.
- ``pytest tests/ -k 'model or picker or badge'``: 1507 passed,
1 skipped, 2 failed. The 2 failures (test_issue1426_free_tier,
test_issue2245_mixed_case) are the documented upstream shard-race
flake — both pass when run individually (verified). Pre-existing
per issue #7656's notes.
- ``ruff check api/config.py tests/test_issue7290_no_slash_prefixed_alias.py``:
clean on the new test file. (The 4 pre-existing F811/import-order
findings in api/config.py are on untouched lines.)
## Contract Change
No API contract change. The ``/api/models`` payload's
``configured_model_badges`` map drops one synthetic key per configured
entry (the slash-prefixed alias). Real consumers that look up a badge
by exact native id or by ``@provider:model`` are unaffected. The
dropdown's existing dedupe is now correct without a manual workaround.
## Risks
- A consumer that depended on the slash-prefixed alias as a stable
lookup key would need to switch to the bare native id. No consumer
in this repo matches that pattern — ``test_duplicate_slash_id_*
_primary_badge_sticks_to_matching_provider_only`` was already
asserting that the bare id is the canonical lookup.
- The dropdown's option value (the string sent as ``model=`` to the
agent) is the bare native id; the test runner, the session
restore path, and the request payload all use the bare id
consistently per maintainer contract 3.
## Model Used
Hermes Agent (step-5-preview) with pytest, ruff. Tests execute the
live ``_build_configured_model_badges`` from the production source
(``exec`` the function body with inline ``_norm_model_id``), so a
revert to the original ``raw_candidates`` tuple fails the suite
immediately on the source-shape test.
## Related
- #7290 — original issue (Porter-Key, 2026-08-25) with the full repro
and the maintainer's 9/25 pickup contract (6 items).
- #3478 — named custom-provider ``@custom:name:model`` prefix.
- #7241, #6884 — adjacent ``@provider:model`` prefix lanes.
- #3360 — multi-slash model id collision (the ``_norm_model_id``
fix predates this PR and is preserved).
- #3429 — URI-scheme model id preservation (also preserved).
- #3959 — colon-suffix model id preservation (also preserved).
## Thinking Path
With native OIDC configured and ``HERMES_WEBUI_PASSWORD`` unset, the
``/api/auth/status`` endpoint correctly reports
``password_auth_enabled: false``, but ``/login`` still rendered the
password input, the "Sign in" button, and the "Continue with SSO"
entry point — and silently 401-ed every submit. The reporter's wire
evidence:
```json
GET /api/auth/status -> {
"auth_enabled": true,
"oidc_enabled": true,
"password_auth_enabled": false
}
GET /login -> still shows:
"Enter your password to continue"
+ password <input>
+ "Sign in" button
+ "Continue with SSO" link
POST /api/auth/login -> 401 {"error":"Invalid password"}
```
The fix routes the password input / submit / passkey controls through
a single ``{{PASSWORD_FORM_HTML}}`` placeholder in the template, which
the handler populates with the live markup only when
``is_password_auth_enabled()`` is True. The OIDC SSO entry point stays
the sole path when password auth is disabled.
## What Changed
1. ``api/routes.py`` template literal (``_LOGIN_PAGE_HTML``): the
three inline elements (password input, submit button, passkey
button) are replaced by a single ``{{PASSWORD_FORM_HTML}}``
placeholder. The ``{{OIDC_LOGIN_HTML}}`` placeholder is kept
untouched.
2. ``api/routes.py`` login branch: the handler imports
``is_password_auth_enabled`` from ``api.auth``, computes the
password form markup when the predicate returns True, and an
empty string otherwise. The ``.replace("{{PASSWORD_FORM_HTML}}", _password_form_html)``
call slots the result into the template. The
``{{LOGIN_PLACEHOLDER}}``, ``{{LOGIN_BTN}}``, ``{{LOGIN_INVALID_PW}}``,
``{{LOGIN_CONN_FAILED}}`` placeholders are no longer referenced
from the template (the password input is built with its own
``placeholder`` attribute), so the four corresponding
``.replace(...)`` calls are removed.
## Why It Matters
A user with OIDC-only auth clicking "Sign in" on the password form
gets a 401 with no UI signal — the OIDC entry point is right there,
but the password prompt was so prominent it read like the primary
path. Hiding the form when password auth is off makes OIDC the only
visible option, so the user is funneled to the working path.
## Verification
- ``tests/test_issue7056_login_hides_password_form.py`` (new, 6 tests):
- **Source-shape (2 tests):** the template uses the
``{{PASSWORD_FORM_HTML}}`` placeholder AND does not carry an
inline ``<input type="password">``; a silent reversion to the
pre-fix multi-line shape fails the suite. The login branch
imports ``is_password_auth_enabled`` and populates the
placeholder.
- **Behavioural (3 tests):** the rendered ``/login`` HTML contains
the password input / submit / passkey controls when
``is_password_auth_enabled()`` is True; does NOT contain them
when False; and keeps the ``<a id="oidc-login">`` SSO entry
point when OIDC is enabled (so the user can still log in).
- **Sanity (1 test):** with both password and OIDC disabled, the
page renders neither control.
- 51 tests pass across the related auth/login files
(``test_issue7056_login_hides_password_form``, ``test_auth_settings_safety``,
``test_issue3825_oidc_auth``) — no pre-existing test is broken.
## Contract Change
None. The ``/login`` HTML body changes shape only when
``is_password_auth_enabled()`` is False; the password input + submit
+ passkey controls are gone, but the OIDC SSO entry point stays
present. The ``/api/auth/status`` contract is unchanged — it already
reported the right ``password_auth_enabled`` flag; the fix is purely
about the page matching the status.
## Risks
- A consumer that scraped the ``/login`` HTML for the password
input (e.g. a browser extension) will see the input disappear
when ``HERMES_WEBUI_PASSWORD`` is unset. The OIDC entry point
remains the only path, which is the intended behaviour when
password auth is disabled.
- The handler now imports ``is_password_auth_enabled`` at request
time (same module as the other auth predicates already imported);
if the auth module is unavailable the import fails fast and the
``/login`` route surfaces the underlying exception, matching the
behaviour of the other auth imports in the same branch.
## Related
- #7056 — original issue (segator, 2026-08-15) with the wire evidence
and the expected-vs-actual contract.
- The OIDC SSO entry point lives in ``_oidc_login_html`` (line 11160)
and is unaffected by this PR — the fix only gates the password
controls, not the SSO button.
## Model Used
Hermes Agent (step-5-preview) with pytest, ruff. Tests use
``_FakeHandler`` with an ``io.BytesIO`` ``wfile`` (matching
``_safe_write``'s ``handler.wfile.write(body)`` expectation) and
mock the OIDC predicate through ``_stub_oidc`` so the
behavioural assertions don't depend on real OIDC env config.
## Thinking Path
When an assistant emits `` `MEDIA:/path` `` wrapped in inline-code markdown, the
shared character class ``[^\s\)\]]+`` across all seven MEDIA capture sites
greedy-matched the closing backtick into the path. The resulting
``/api/media?path=...%60`` URL 404'd on the legitimate file (issue #7359).
The same drift between frontend linkification and backend allowlist means
even if the URL is constructed with the trailing backtick, the session
allowlist rejects the backtick-polluted path — the user gets a broken
download and a 404 body saved as ``.json``.
Verified the issue's repro: 6 sites share ``[^\s\)\]]+`` (the
preview-flattener at ui.js:9142 is ``[^\s]+`` and is a 7th site, also
covered). One additional site, ``messages.js:4896`` (combined walk),
was missing from the original 6-site list in the issue body — the
suggested fix shape applies to all seven.
The fix is a one-character-set widening: add `` ` `` to the excluded
class on every site so the capture stops at the markdown delimiter
instead of including it.
## What Changed
All seven sites: ``[^\s\)\]]+`` → ``[^\s\)\]\`]+`` (JS) and the
Python equivalent ``MEDIA:([^\s\)\]\`]+)``:
1. ``static/ui.js:7695`` — linkifier regex (the front-door capture).
2. ``static/ui.js:9142`` — preview-text flattener (also drops ``)`` and
``]`` for symmetric handling; was ``[^\s]+`` which would still pull
the backtick into the preview).
3. ``static/messages.js:4848`` — streaming chunk finalize.
4. ``static/messages.js:4896`` — streaming combined walk (the
issue-missing 7th site).
5. ``static/messages.js:4922`` — streaming partial tail.
6. ``api/routes.py:20453`` — ``_MEDIA_TOKEN_RE`` (the session allowlist).
7. ``api/media_snapshots.py:507`` — ``media_re`` (settle-time snapshot
capture).
## Why It Matters
A user clicking an inline-code-wrapped MEDIA token gets a 404 body saved
as ``.json`` instead of the file they asked for. On Android Chrome (and
desktop Chrome), the 404 silently downloads as ``skills.zip.json`` with
no UI signal that the original was missing. The backend allowlist and
the frontend linkifier must agree, so the fix widens the character
class on both sides.
## Verification
- ``tests/test_issue7359_media_backtick_strip.py``: 17 passed (new).
- 7 site-shape assertions (parametrized over the seven sites).
- ``_MEDIA_TOKEN_RE`` behaviour: strips trailing backtick from inline-code
payload, preserves bare-token capture, preserves trailing-``)`` exclusion.
- ``media_re`` behaviour: same three.
- pre-fix class round-trip: ``\`MEDIA:/a.zip\``` → ``["/a.zip\`"]``
(the reported bug); post-fix → ``["/a.zip"]``.
- ``node --check`` on the two modified JS files.
- ``pytest tests/ -k 'media and not 7359'``: 291 passed, 1 skipped,
0 failed — no existing media test regresses.
- ``ruff check tests/test_issue7359_media_backtick_strip.py``: clean.
- ``scripts/ruff_lint.py --diff origin/master`` curated gate: 0 new
findings on added/modified lines.
## Contract Change
No API contract change. UI rendering may now display
``somefile.zip`` (a file) where it previously displayed
``somefile.zip\``` (literal backtick in the preview); both already
displayed correctly in the bare-token case, and the inline-code path
was the broken one.
## Risks
- Sites that depended on the backtick being included in the path would
regress, but no consumer in this repo treats a trailing `` ` `` as
part of a MEDIA path — the original six-site fix shape was
maintainer-recommended and the 7th site matches the same pattern.
- The preview flattener at ``ui.js:9142`` previously used a wider class
(``[^\s]+`` only, no ``)``/``]``) so it would have matched a path
containing a literal ``)``. The fix tightens that to match the
other six sites; if anyone relied on the looser class the rendering
would now use ``a file`` for the surrounding paragraph. Verified no
test in the suite exercises a MEDIA token containing ``)``.
## Model Used
Hermes Agent (step-5-preview) with pytest, ruff, and node. Tests are
executed against the live compiled regexes (for the two Python sites)
and against the literal source text (for the five JS sites), so a
revert on any of the seven sites surfaces as a source-shape failure.
## Related
- #7359 — original issue (KimSpecht, 2026-08-29) with the repro and
six-site list.
- #7334 — pre-existing PR (angelusbr, 2026-08-27) that covers three
sites; this PR supersedes it by covering all seven and following the
maintainer-recommended shape from the 9/20 comment.
- Maintainer's 9/20 comment on #7359 named #7334 as "the correct
existing implementation lane"; if #7334 lands first, this PR is
closeable as duplicate and the maintainer's ``sprint-candidate``
label can move. Credit goes to angelusbr for the early character-class
idea regardless of which PR merges.
## Thinking Path
When a tab is hidden, Hermes WebUI replaces the persistent
per-session SSE connection with a lightweight ``GET /api/session/status``
poll every six seconds. If the tracked session has been deleted
or no longer exists in the active state directory, the endpoint
returns ``404`` — but the client previously converted every non-2xx
response to ``null`` and kept the interval alive forever. This
produces an invisible stream of permanent 404 requests from stale
background tabs (one tab generates ~10 requests per minute when
browser timer throttling allows it; multiple stale tabs multiply
the noise).
The fix narrows the stop condition to the truly terminal responses
(``404 Not Found`` and ``410 Gone``) and calls
``_stopHiddenActiveStreamPoll()`` so the interval and the bound
``_sessionStreamHiddenPollSid`` are cleared together. Transient
failures (5xx, rate-limit, network error) keep polling because they
do not necessarily mean the session is gone; a later tick may catch
a server-initiated turn starting while the tab remains hidden.
## What Changed
``static/messages.js`` — ``_startHiddenActiveStreamPoll()``'s fetch
chain now branches on ``r.status`` before the ``r.ok ? r.json() : null``
fallback:
```js
fetch(...)
.then(r => {
// #7299: 404 Not Found / 410 Gone are TERMINAL for this
// session-owned poll. ... only the missing
// session itself is terminal.
if ((r.status === 404 || r.status === 410) && _sessionStreamHiddenPollSid === sid) {
_stopHiddenActiveStreamPoll();
return null;
}
return r.ok ? r.json() : null;
})
.then(d => { ... });
```
The pre-existing ``r.ok ? r.json() : null`` fallback is preserved
for any non-2xx, non-terminal response, so a 5xx / rate-limit /
network error still produces ``null`` and the next tick re-fires.
A regression that broadened the stop path to all non-2xx would
re-introduce the original bug in the opposite direction (the poll
would die on a single 503 and never recover).
## Why It Matters
A stale background tab with a deleted session could hold an
interval running for hours, firing a 404 every six seconds. The
user sees nothing because the tab is hidden; the server sees
inbound requests that are guaranteed to fail; the request log
fills with noise that an operator has to triage. The fix is
mechanical — the server already returns the right status code, the
client just needs to honor it.
## Verification
- ``tests/test_issue7299_hidden_poll_stops_on_404.py`` (new, 5 tests):
- **404 stops the poll** — source-shape check on the response
handler; a silent reversion to the pre-fix
"convert all non-2xx to null and keep polling" form fails
the suite.
- **410 stops the poll** — same shape, separate code branch
so a future refactor cannot satisfy the 404 check with a
single OR expression.
- **bound sid guards the stop** — a stale fetch's response
landing after a session switch must not tear down the *new*
session's poll.
- **5xx / rate-limit / network errors keep polling** — the
``r.ok ? r.json() : null`` fallback must remain.
- **6000ms interval + immediate-fire preserved** — the fix is
in the response handling, not the cadence.
- ``tests/test_hidden_tab_server_initiated_turn.py`` — pre-existing
tests for the hidden-tab poll (8 tests). The 4 that use
``MESSAGES_JS[start:start + N]`` slicing had their window widened
(2400/3200 → 8000) to cover the new 404/410 branch.
- 33 tests pass across the related session/stream/hidden files
(``test_hidden_tab_server_initiated_turn``,
``test_issue7299_hidden_poll_stops_on_404``,
``test_issue3718_live_models_custom_probe``,
``test_issue6419_pending_user_reconnect_order``,
``test_session_search_bfcache_822``) — no pre-existing test
is broken.
## Contract Change
None. The server already returned the right status code
(``404`` for a missing session); the fix is purely about the
client honoring it. No new public API, no breaking change
to the SSE lifecycle, no change to the visible-tab path.
## Risks
- A regression that broadens the stop path to all non-2xx would
re-introduce the original bug in the opposite direction (the
poll would die on a single 503 and never recover). The new
``test_hidden_poll_preserves_transient_failure_retry`` pins
this contract.
- The 404/410 stop path checks the bound
``_sessionStreamHiddenPollSid === sid`` before stopping, so a
stale fetch's response landing after a session switch cannot
tear down the *new* session's poll. The new
``test_hidden_poll_404_stop_guards_on_bound_sid`` pins this
contract.
## Related
- #7299 — original issue with the wire evidence and the
expected-vs-actual contract.
Core's 2026-09-09 catalog sync (hermes_cli/models_catalog_static.py, unreleased
as of v2026.9.7) dropped ox-alpha-free — the Go relay delisted it (GET
/zen/go/v1/models omits it, POST returns 401) — and added glm-5.3-flash and
muse-spark-1.3-contributor. Mirror core main (29 ids, verified exact match).
Keeping the delisted id would offer a model that always 401s — the original
#5311 failure class.
The installed-core equality test now skips while released cores still carry
the delisted id (transition window documented in the test); verified 7/7
green with PYTHONPATH pointed at core main where equality binds.
The WebUI fallback list mirrors Hermes core's curated opencode-go list
(hermes_cli/models.py, synced 2026-08-20/21 against the live
/zen/go/v1/models endpoint and the public Go docs). Core owns the sync
duty; WebUI only mirrors. Adds the nine ids core gained since the
2026-07-07 snapshot (kimi-k3, gpt-5.6-luna, glm-5.3, grok-4.5,
qwen3.8-max, hy3, hy3-preview, muse-spark-1.2-contributor,
ox-alpha-free) and reorders to core's order so future diffs are
mechanical. Snapshot test updated accordingly.
Remove the #5611 stopgap that skipped the live /v1/models probe for
OpenCode Go. Its premise — the probe returning public-catalog models
not enabled on the Go tier — no longer holds: Hermes core v0.20.5
(fcbd1076) ships a Go-specific provider profile probing
https://opencode.ai/zen/go/v1/models directly and merges results
live-first with its curated Go list (#49129).
WebUI again delegates like every other provider (#1240): live catalog
first, curated static _PROVIDER_MODELS as offline fallback only.
Runtime tests pin the behavior (sentinel live id must reach the Go
group; probe failure falls back to static; providers.opencode-go.models
allowlist still wins #644).
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.
The route-level regression for a denied non-head run approval lives in
tests/test_approval_unblock.py, which imports tools.approval._ApprovalEntry at
module scope. That file skips wholesale when the installed agent no longer
exposes the facade — the approval-module split dropped it — and also skips in CI,
where no agent is installed at all.
Nothing covered the wiring this fix changes. Only the response route supplies a
target's (run_id, approval_id) to the legacy resolver, and that pair is what
reaches the exact-run resolver; a regression there drops the producer without
waking its waiter, leaving the agent thread parked until the approval timeout.
Add the route-level regression to tests/test_gateway_approval_legacy_path.py,
which runs wherever the suite runs. It answers a non-head approval through
_handle_approval_respond with the payload the UI sends, with the run's head, a
same-run sibling, and a producer from an unrelated run also queued. It asserts
the target's producer is consumed AND its waiter woken, that the three survivors
stay queued and unsignaled, and that two reconciliation passes do not rebuild
the denied mirror. Producers are the agent's real _ApprovalEntry where that is
importable and an equivalent stand-in where it is not, so the assertions mean
the same thing in both shapes.
Fails on unmodified master with the agent present and absent; passes with the
fix. Mutation-checked by resolving every same-run producer instead of the exact
one — the survivor assertions catch it.
Additive commit: the earlier commits are published, so published history is
preserved rather than rewritten.
Two repository-guide violations raised in review on #7570.
Selection is what this fix changes, but the regression test seeded only the
producer being retired. It now seeds a sibling approval on the same run and a
producer belonging to an unrelated run, then asserts the target is consumed,
both survivors remain queued, and reconciliation does not resurrect the denied
approval's mirror. Mutation-checked so the new assertions have teeth: an
over-aggressive filter that retires every same-run entry fails on the sibling
assertion, and the unfixed implementation fails on the target assertion.
Runtime behaviour also changed without tracked documentation of the invariant.
The api/route_approvals.py module docstring now records the producer/mirror
ownership direction and the retirement rules, including why an unattributable
same-run producer is retired with its run.
Additive commit: the previous commit is published and under review, so the
reviewed history is preserved rather than rewritten.