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.)
On native Windows the text-mode ``handle.write("\n")`` that seeds an empty
``--lock-file`` is translated to ``\r\n``, so a fresh lock file is two bytes
and the msvcrt regression test fails on the platform it protects. Seed the
file with ``os.write(fd, b"\0")`` on the descriptor instead: exactly one byte
whatever the text-mode newline translation. The Windows-branch tests now run
under a ``\n`` -> ``\r\n`` translating ``open`` so this failure reproduces
on any host.
Remove the two ``... not in src`` assertions added for the previous review
from test_issue2628: the behaviours they hinted at (every missing-index
projection uses the pre-aggregated ordering; listing never creates an
index) are already asserted through query plans, result equivalence and
``PRAGMA index_list`` in test_pr7445_readonly_review_followups.py,
test_state_db_readonly_readers.py and test_issue3887_index_prime.py.
On the msvcrt branch of scripts/ensure_state_db_read_indexes.py the tool
locked byte 0 of --lock-file right after open(..., "a+"). When that open
had just created the file (or the file was empty) the lock range lay beyond
EOF, which Windows refuses, so the documented locked-maintenance workflow
raised instead of acquiring the lock (greptile P1).
Write one byte when the lock file is empty, then seek(0) and lock as
before. Only the Windows branch changes; POSIX flock is untouched and an
existing (deployment-owned) lock file is never rewritten.
Tests (tests/test_pr7445_readonly_review_followups.py): a strict msvcrt
stand-in that refuses to lock past EOF proves the lock is taken on byte 0
of a fresh empty file, that an existing lock file keeps its content, and
that a held lock still propagates before the database is touched.
Review follow-ups on #7445 (nesquena-hermes CHANGES_REQUESTED + Greptile).
api/agent_sessions.py
- The read-only pre-aggregated `latest_messages` candidate ordering now
applies to every missing-index projection (default sidebar, gateway
watcher, cron/webhook/kanban views), not only `include_sources ==
("cron",)`. Since the listing no longer self-heals the index, the
correlated `MAX(mx.timestamp)` scan re-ran on every load: 9.86 s on a
5,000-session / 20,000-message DB vs 0.044 s after this change, with
identical selected rows and order. The indexed seek is unchanged.
- `state_db_file_uri()` / `state_db_readonly_uri()` replace
`Path.as_uri()`: a UNC `HERMES_HOME` produced `file://server/share/...`
and CPython's bundled SQLite (3.11-3.13 Windows builds, no
SQLITE_ALLOW_URI_AUTHORITY) rejected the authority, so every agent-backed
session vanished. The builder emits the empty-authority
`file:////server/share/state.db?mode=ro`, keeps `file:///C:/...` for
drive letters, strips `\\?\` / `\\?\UNC\` prefixes, quotes `?`/`#`, and
leaves POSIX output byte-identical. Strict `mode=ro`, no writable
fallback.
scripts/ensure_state_db_read_indexes.py
- `fcntl` / `msvcrt` are imported conditionally; the module imports and
the no-`--lock-file` path runs on native Windows. `--lock-file` uses
`flock` on POSIX, `msvcrt.locking(LK_NBLCK)` on Windows, and fails with
a clear RuntimeError when neither exists. The rw open reuses the
UNC-aware URI builder.
docs/troubleshooting.md
- Operator section: read-only contract, degraded missing-index listing,
drained maintenance prerequisites, lock behaviour, failure modes, UNC.
Tests: tests/test_pr7445_readonly_review_followups.py (19 tests, red
before this change: correlated-subquery plan, missing URI builder,
ImportError on fcntl). Query-plan contract via EXPLAIN QUERY PLAN — no
CORRELATED SCALAR SUBQUERY on missing-index listings, identical rows to
the indexed listing; platform-parametrised URI cases; maintenance module
imported with `sys.modules["fcntl"] = None`. test_issue2628 no longer
pins the cron-only gate; the flock-held test skips without fcntl.
Readers never upgrade to writers. open_state_db_readonly() opens
file:...?mode=ro only; a read-only open failure propagates instead of
silently retrying with a write-capable handle on the multi-GiB WAL
database the agent is streaming into. The session listing no longer
self-heals idx_messages_session with CREATE INDEX (a writer lock held for
minutes on a large messages table) — a missing index degrades to the
pre-aggregated path. Cron sidebar info, insights and deep health checks in
api/routes.py use the same read-only opener.
Watcher (api/gateway_watcher.py): _poll_once returns without touching the
database when nobody is subscribed, and forces a fresh full projection on
the next subscription.
Maintenance (scripts/ensure_state_db_read_indexes.py): the covering read
indexes (idx_messages_session, idx_messages_session_role with NOCASE,
idx_sessions_webui_fingerprint) are created only under --confirm-drained,
optionally holding an exclusive flock on --lock-file for deployments that
serialise agent turns on a lock file; existing indexes are verified for
shape/collation and coverage (EXPLAIN QUERY PLAN), never silently
replaced.
Tests: tests/test_state_db_readonly_readers.py (read-only failure never
retries writable; no poll without subscribers; listing creates no index;
index tool requires drain, is idempotent, works without a lock file and
refuses a held lock; cron sidebar read is mode=ro). Neighbouring tests
that asserted the retired writable fallback / self-heal writer
(test_issue3887_index_prime, test_issue5455_listing_readonly_connection,
test_issue5455_lineage_readonly_reads, test_issue2628_cli_sessions_perf,
test_issue1494_state_db_fd_leak) now assert the read-only contract;
test_issue3506 subscribes before polling.
* ci: docs-only fast path + minimal docs CI
Skip the full pytest matrix + browser smoke on docs/CHANGELOG/README-only PRs
(required checks still report green fast via a fail-safe 'changes' gate job), and
add a lightweight Docs CI: critical_markdown_check.py (rendering-breaks only, not
style) + lychee broken-link check. Detection fails safe (any uncertainty or any
non-doc path -> full suite runs).
* ci: tighten docs-only detection — extension/type wins over name
A code file whose NAME contains README/CHANGELOG (scripts/CHANGELOG_stamp.py,
static/README_renderer.js) was wrongly classified docs-only, which would SKIP the
test suite on a real code change. Now a path is docs only by doc extension, exact
doc basename, or non-code file under docs/. Verified against 16 cases incl. every
code-with-docs-name trap.
* ci: fix all Fable+Codex gate findings on docs-only fast path
- BLOCKER: drop *.txt from is_docs (requirements.txt is a dep manifest — a bump
would have skipped the whole test matrix). docs = *.md/.markdown/.rst + bare
doc basenames only; strict allowlist, no docs/** denylist.
- Rename hole: use 'git diff --name-only --no-renames' so a rename src/app.py ->
docs.md reveals the code-side deletion instead of collapsing to the doc dest.
- SECURITY: docs-ci.yml no longer interpolates untrusted fork-PR filenames into
run: via ${{ }} (which executes $() in a crafted name). File list flows through
a file + mapfile-as-args; lychee gets a fixed glob, not the attacker list.
Added permissions: contents: read.
- Wedge belt-and-suspenders: 'if: ${{ always() }}' on the required test +
browser-smoke jobs so a failed 'changes' job can't skip them; step guards treat
missing/empty docs_only as run-full.
- critical_markdown_check.py: corrected the core rule — a newline in the whitespace
AROUND a link destination is valid CommonMark (was a false positive); only a
newline INSIDE the destination token, or an unclosed inline link, breaks
rendering. Verified full agreement with the markdown-it-py reference parser +
0 false positives on all 43 repo docs. Also blank 4-space indented code + multi
backtick spans. Reworded 'block the merge' -> 'break rendering' (non-required).
* ci: address Codex re-gate — lint always() + markdown title/unclosed cases
- Add if: always() to the lint job too (not currently required, but future-proof
against the wedge class if it's ever promoted).
- critical_markdown_check.py: handle two more CommonMark cases Codex found —
a newline inside a "title" string is legal (skip), and a newline-terminated
unclosed dest ([x](url\n at EOF/EOL with no close) is broken (flag). After the
destination token ends, valid continuations are ')' or a title opener (" ' ();
bare text after the newline is the real break. Verified full agreement with
markdown-it-py across 13 cases + 0 FP on all 43 repo docs.
* ci: model the CommonMark inline-dest grammar (root-cause fix for markdown checker)
Round-3 gate found the title heuristic caused sibling regressions: a parenthesized
multi-line title (url (a\nb)) was falsely flagged, and a quote glued into the URL
(exa"part) was wrongly treated as a title-start and skipped. Rather than patch more
heuristics, replace the ad-hoc newline logic with _scan_inline_dest(), which walks
the actual grammar: skip leading ws -> bare dest (balanced parens, ends at ws or the
depth-0 ')') or <angle> dest -> after ws the next char must be ')' or a real title
opener (" ' () -> else the destination is split across the line (broken). Verified
FULL agreement with markdown-it-py across 20 adversarial cases (incl. both round-3
regressions, balanced parens, angle dests, multiline titles) + 0 FP on all 43 docs.
* test: pytest suite for critical_markdown_check (42 cases)
Durable, repeatable verification for the docs-CI markdown checker: 19 verdict cases
+ 19 cross-checked against the markdown-it-py CommonMark reference (skips cleanly if
the lib is absent) + 3 code-span-safety cases + empty/no-link inputs. Covers both
round-3 regressions (parenthesized multi-line title valid; quote-glued-in-URL broken),
balanced parens, angle destinations, and multiline titles. 42 passed.
* ci: fix 2 grammar edges from Codex round-4 (escaped-> in angle dest, unbalanced bare-dest parens)
- Angle dest <...> now honors backslash escapes: [x](<foo\>bar>) renders (the \> is
escaped), was a false positive.
- Bare dest must have BALANCED parens: [x](foo(\n)) does not render (a '(' stays open
when whitespace ends the token) — now returns split, was a false negative.
Both verified against markdown-it-py + added as pytest cases. 46 passed, 0 FP on 43 docs.
* ci: escaped-newline in angle dest is still a raw newline (Codex round-5)
<...> escape handling skipped the char after backslash including a newline, so
[x](<foo\<nl>>) returned ok but doesn't render (blockquote on line 2). An escaped
newline inside an angle destination is still a raw newline -> split. Preserves
[x](<foo\>bar>). Added regression case. 48 pytest cases pass, 0 FP on 43 docs.
* ci: mirror escaped-newline guard to bare dest (Codex round-6, GFM-correct)
The angle branch already treated backslash-newline as split; the bare branch skipped
it, so [x](foo\<nl>bar) returned ok. GFM/CommonMark forbid line endings in bare
destinations (GitHub's cmark-gfm won't render it), though markdown-it-py permissively
does. Since these docs are GitHub-rendered we follow GFM: flag it. Added as a
SPEC_DIVERGENT test case (verdict-asserted, excluded from the permissive parser
cross-check). 49 pytest pass, escaped paren/space still valid, 0 FP on 43 docs.
* ci: delimiter-aware title scan (Codex round-7 unclosed-link false negatives)
Naive text.find(')') matched the TITLE's own ')' not the link's outer ')', so
[x](foo (title), [x](<foo> (title), [x](foo "title" all returned ok despite being
unclosed links. Now parse the title to its actual closing delimiter ("..", '..',
or a (..) that forbids nested unescaped '(' per CommonMark), then require the link's
own ')' after optional whitespace. Also catches the nested-paren-title break
[x](foo (a (b) c)). Verified vs markdown-it-py; escaped parens + single-quoted
titles containing parens still valid. 61 pytest pass, 0 FP on 43 docs.
* ci: remove unused variable (ruff F841 in critical_markdown_check)
The CI lint gate (ruff forward E9+F+B on new lines) caught a dead 'stripped =
line.lstrip()' leftover from an earlier refactor in _blank_code — the fence
detection matches on the raw line. Removed. No behavior change (61 pytest pass).
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Absorbs #4412 (tomas-forgac) rebased onto fresh master, plus two
Codex-regression-gate fixes: (1) use the scheme that actually answered the
probe for the ready/already-up URL + browser-open, (2) honor an explicit
HERMES_WEBUI_HEALTH_URL override in the WSL autostart probe.
Co-authored-by: tomas-forgac <tomas-forgac@users.noreply.github.com>
Closes#4412
* fix(sidebar): hoist _sessionAttentionState to top-level scope (#3696)
_sessionAttentionState was declared inside renderSessionListFromCache() and
relied on function hoisting, but the top-level function _sidebarRowHasVisible
Messages (reached via renderSessionListFromCache -> _partitionSidebarSessionRows)
called it bare. Hoisting is scoped to the enclosing function, so every sidebar
cache-render threw 'ReferenceError: _sessionAttentionState is not defined' and
the session list went blank. Regressed in #3672 (v0.51.269) when _sidebarRow
HasVisibleMessages was extracted to top level.
Fix: move _sessionAttentionState to top-level scope (it is pure — only uses its
arg plus the i18n global t), so both the visibility predicate and the nested
per-row renderer can reach it.
Prevention (the durable half): add scripts/scope_undef_gate.py — models the
classic-<script> shared global scope (union of all static files' top-level
symbols) and runs ESLint no-undef per file, flagging a function defined nested
but called from a sibling scope. Wired into CI (.github/workflows/tests.yml lint
job) alongside the existing no-const-assign runtime gate, plus an in-suite test
(test_static_js_scope_undef.py) and a focused structural regression test
(test_issue3696_session_attention_scope.py). RED/GREEN-validated against the
broken tree.
* fix(streaming): thread source param into stale-stream bailout; tighten scope gate
Opus review of #3698 found the new scope_undef_gate's 'source' allowlist entry
was masking a real same-class bug: _bailOutOfTerminalEventsFromStaleStream
(declared inside attachLiveStream, params activeSid/streamId/uploaded/options)
called _closeSource(source) against a 'source' not in its lexical scope. All 5
call sites are inside _wireSSE(source), but JS scope is lexical not dynamic, so
the helper would throw ReferenceError: source is not defined on the stale-stream
terminal-event path (user back in an active session whose old stream finalizes
late).
Fix: thread source as an explicit parameter (declaration + all 5 call sites),
the same make-the-dependency-explicit fix as #3696 — and REMOVE the 'source'
allowlist entry so the gate stays gated against that name (it now passes because
the bug is fixed, not because it's allowlisted). Added the documented
false-negative classes from Opus's review to the gate docstring (name-collision
shadowing, destructuring-regex gap, exposure escape hatches, name-keyed
allowlist) and a focused regression test.
This is the prevention gate catching a real latent bug on its first outing.
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>