Skip to content

fix(css): mobile responsive layout and dvh viewport fix - #5

Merged
nesquena merged 1 commit into
nesquena:masterfrom
deboste:fix/mobile-responsive
Apr 2, 2026
Merged

fix(css): mobile responsive layout and dvh viewport fix#5
nesquena merged 1 commit into
nesquena:masterfrom
deboste:fix/mobile-responsive

Conversation

@deboste

@deboste deboste commented Mar 31, 2026

Copy link
Copy Markdown
Contributor
  • Use 100dvh with 100vh fallback to fix composer being cut off on mobile browsers where the address bar affects viewport height
  • Add comprehensive @media(max-width:640px) rules: topbar wrapping, compact messages, full-width msg-body, smaller chips and buttons, responsive composer, approval cards, tool cards, settings modal
  • Use font-size:16px on textarea to prevent iOS/Android auto-zoom on input focus (browsers zoom when font-size < 16px)
  • Add .topbar-left class on title wrapper for responsive stacking

- Use 100dvh with 100vh fallback to fix composer being cut off on
  mobile browsers where the address bar affects viewport height
- Add comprehensive @media(max-width:640px) rules: topbar wrapping,
  compact messages, full-width msg-body, smaller chips and buttons,
  responsive composer, approval cards, tool cards, settings modal
- Use font-size:16px on textarea to prevent iOS/Android auto-zoom
  on input focus (browsers zoom when font-size < 16px)
- Add .topbar-left class on title wrapper for responsive stacking

@nesquena nesquena left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: PR #5 — mobile responsive layout and dvh viewport fix

Summary

Clean, well-structured CSS-only PR that addresses real mobile usability issues.

What's Good

  1. 100dvh with 100vh fallback — Correct progressive enhancement pattern. Browsers that don't support dvh use the first declaration; modern mobile browsers use the dynamic viewport height to account for browser chrome. This fixes the composer being cut off on mobile.

  2. font-size: 16px on textarea — Smart fix for a well-known iOS/Android issue where browsers auto-zoom on inputs with font-size < 16px. This prevents the jarring zoom-on-focus behavior.

  3. Comprehensive mobile rules — Covers topbar wrapping, message compaction, composer responsiveness, approval/tool cards, and settings modal. All reasonable choices.

  4. .topbar-left class — Properly coordinated between HTML and CSS. The flex: 1 1 100% forces title to take full width on mobile, pushing chips to next line for a stacked layout.

  5. Limited !important usage — Only on chips/buttons where specificity against inline styles is needed. Acceptable.

Tests

Ran full test suite: 201 passed, 23 failed — all 23 failures are pre-existing. No regressions.

Verdict

This is a low-risk CSS/HTML-only change with no logic modifications. Looks good to merge. Approved.

@nesquena
nesquena merged commit 0a39a64 into nesquena:master Apr 2, 2026
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

@deboste Thanks!

MartinNielsenDev added a commit to MartinNielsenDev/hermes-webui that referenced this pull request Apr 4, 2026
Features:
- Subagent session tree with expand/collapse in sidebar
- Unified tool card rendering with diff/terminal/todo views
- requestAnimationFrame throttled token streaming
- Clarify dialog with threading.Event pattern
- Git modal with branch/status/log
- Collapsible date groups in session list
- Context usage indicator in composer
- VS Code Dark+ theme with Font Awesome icons
- Workspace explorer with labels, tags, breadcrumbs
- Auto-generated session titles via LLM
- Unified session storage via SessionDB
- Inline tool cards during streaming
- Gateway approval polling support
- Telegram session indicator

Security (audit fixes):
- Restore XSS protection in renderMd() with esc() sanitization and SAFE_TAGS
- Restore credentials:include on all fetch/EventSource calls (PR nesquena#3)
- Restore mobile responsive CSS (PR nesquena#5)
- Fix sendCompress() calling undefined sendMessage() -> send()
- Fix db.add_message() -> db.append_message()
- Remove hardcoded su -l user, use HERMES_DESKTOP_USER env var
- Preserve upstream security controls (path traversal, headers, body limit, loopback guard)
- Restore SESSION_DIR/SESSION_INDEX_FILE exports
- Remove unused _write_session_index imports
Ola-Turmo pushed a commit to Ola-Turmo/hermes-webui that referenced this pull request Apr 9, 2026
Reviewed: CSS/HTML only, security audited, test suite passes (201/201 relevant tests). Clean mobile responsive fix.
nesquena-hermes pushed a commit that referenced this pull request Apr 12, 2026
- Title/badge/source: 2025 → 2026, sources updated to Chatbot Arena/BenchLM
- Overall: Opus 4.6 (#1, 1504 Arena Elo), Gemini 3.1 Pro (#2), GPT-5.4 (#3),
  Sonnet 4.6 (#4), DeepSeek V3.2 (#5, replaces DeepSeek R1)
- Coding: Opus 4.6 (#1, powers Claude Code/Cursor), GPT-5.4 (#2, SWE-Pro 57.7%),
  Sonnet 4.6 (#3), Gemini 3.1 Pro (#4), DeepSeek V3.2 (#5)
  Removed: Sonnet 4.5, Gemini 2.5 Pro, GPT-5.3 Codex
- Writing: Opus 4.6 (#1, Mazur 8.53), Gemini 3.1 Pro (#2, Arena CW 1487),
  Sonnet 4.6 (#3, EQ-Bench CW 1936), GPT-5.4 (#4), Meta Muse Spark (#5)
  Removed: Llama 4 Maverick (replaced by Meta Muse Spark)
- Search: Gemini 3.1 Pro (#1), Grok 4 (#2, live X data), Sonnet 4.6+tool (#3),
  GPT-5.4 (#4), Gemini 3 Flash Thinking (#5)
  Added Grok 4 for real-time social/X data
- Reasoning: Gemini 3.1 Pro (#1, GPQA 95.45%), GPT-5.4 (#2), Opus 4.6 (#3),
  Gemini 3 Flash Thinking (#4, best value), DeepSeek V3.2 (#5)
  Removed: Kimi K2, DeepSeek R1 standalone
- Quick picker: all model names updated to 2026 versions
- Setup boxes: OpenAI gpt-5-4 names, Google gemini-3-1-pro-preview,
  self-hosted section updated to DeepSeek V3.2 + Muse Spark
nesquena-hermes pushed a commit that referenced this pull request Apr 12, 2026
Coding section:
- Gemini 3.1 Pro: add Terminal-Bench 78.4% (highest of any frontier model on CLI/DevOps)
  Score badge updated to show Terminal-Bench rather than SWE-bench Verified
- GPT-5.4: note Terminal-Bench 75.1% in description, consolidate pill text
- #5 DeepSeek V3.2 → Qwen 3.6-Plus: leads Terminal-Bench at 61.6%, 88.2% GPQA,
  1M context, available now on Alibaba Cloud + OpenRouter

Writing section (reordered based on EQ-Bench CW scores):
- #1 Claude Sonnet 4.6 (1936 EQ-Bench CW — highest, best voice consistency)
- #2 Claude Opus 4.6 (Mazur 8.53, IF Arena #1, 1M context for literary depth)
- #3 Gemini 3.1 Pro (Arena CW #1 1487, AI-tell avoidance, 2M context)
- #4 GPT-5.4 (noted as ~9th on Arena CW, better for structured/commercial writing)
- #5 Meta Muse Spark → Kimi K2.5 (/usr/bin/bash.60/.50, ~1700 EQ-Bench CW, live API)
  Muse Spark removed — no commercial API available yet

Reasoning section:
- Gemini 3.1 Pro GPQA: 95.45% → 94.1% (more conservative/recent figure, consistent
  with both agents' data)
- Added ARC-AGI-2 77.1% for Gemini 3.1 Pro (#1 on visual reasoning too)
- Opus 4.6: added note that Sonnet leads GDPval-AA (1633 Elo #1) for throughput
- #5 DeepSeek V3.2 → Qwen 3.6-Plus (88.2% GPQA, 1M context, same model as coding)

Quick picker:
- Creative writing: Opus → Sonnet 4.6 (EQ-Bench #1, 85% cheaper)
- Hard reasoning: 95.45% → 94.1%, add ARC-AGI-2 mention
- Budget pick: DeepSeek V3.2 → Gemini 3 Flash Thinking (/usr/bin/bash.50/1M, 89.8% GPQA)

Setup boxes:
- Self-hosted: Muse Spark → Qwen 3.6-Plus + Gemma 4 26B MoE (Apache 2.0,
  82.3% GPQA with 3.8B active params, best edge/self-hosted reasoning)

Overall section: unchanged (top 5 still correct per both agents)
Search section: unchanged (no new data from either agent)
JKJameson pushed a commit to JKJameson/hermes-webui that referenced this pull request Apr 25, 2026
Reviewed: CSS/HTML only, security audited, test suite passes (201/201 relevant tests). Clean mobile responsive fix.
nesquena-hermes added a commit that referenced this pull request May 4, 2026
SHOULD-FIX #1 (renamed-root client cross-alias): drop strict-equality client
filter at static/sessions.js:1853. Server-side _profiles_match cross-aliases
'default'-tagged rows to a renamed root 'kinni'; the strict-equality client
would reject them, dropping every legacy session for renamed-root users. The
server is now solely authoritative for profile scoping.

SHOULD-FIX #2 (messaging-source dedupe ordering): _keep_latest_messaging_session_per_source
now runs AFTER the profile filter at api/routes.py:2078. Before, it ran on
the merged-cross-profile list with profile-blind keys, discarding the older
profile's row across profiles before the scope filter — leaving zero rows for
any messaging identity the active profile shared with another profile.

NIT #3: _projects_migrated flag now set only AFTER successful save_projects.
NIT #4: cleaned dead test code in test_is_root_profile_invalidation_drops_stale.
NIT #5: _create_profile_fallback's clone_from=='default' literal now routes
through _is_root_profile() for parity with the 5 other callsites.

+2 regression tests pin the SHOULD-FIX shapes:
- test_keep_latest_messaging_runs_after_profile_filter (source-string ordering)
- test_static_sessions_js_trusts_server_profile_scoping (no client re-filter)

4173 -> 4175 tests pass. 0 regressions.
Isla-Liu pushed a commit to Isla-Liu/hermes-webui that referenced this pull request May 18, 2026
…Copilot nesquena#5)

manual_repro_wakeup_hang.py sat in the pytest test root with a
hardcoded /home/islaliu/.hermes/... venv path in its docstring.
pytest never ran it (no test_ functions) but it was confusing.

- git mv tests/manual_repro_wakeup_hang.py -> tests/manual/repro_wakeup_hang.py
- Strip the operator-specific absolute interpreter path from the
  docstring; the run instruction is now parametric ("python
  tests/manual/repro_wakeup_hang.py" from repo root) and fix the
  sys.path repo-root math for the now-deeper location.
- Exclude tests/manual/ from collection via collect_ignore_glob in the
  existing tests/conftest.py rather than a nested conftest.py: a second
  conftest module would collide with the suite's top-level
  'from conftest import TEST_BASE/TEST_STATE_DIR' imports (no packages
  / rootdir-relative module names).

Verified: pytest --collect-only tests/ collects 6014 tests, 0
collection errors, the manual script no longer surfaces.
Isla-Liu pushed a commit to Isla-Liu/hermes-webui that referenced this pull request May 18, 2026
…esquena#5 follow-up)

Copilot review nesquena#5 'strip local paths' missed one residue:
tests/test_wakeup_model_resolve_hang.py:3-4 still embedded the
reviewer's machine path /home/islaliu/.hermes/webui/bootstrap-8787.log.

Rewrote lines 3-4 to a neutral description ('a live thread-stack
capture of a hung wakeup turn') — root-cause explanation itself
unchanged. Full re-grep of tests/ + tests/manual/ confirms this was
the only remaining residue. 7/7 tests pass.
Isla-Liu pushed a commit to Isla-Liu/hermes-webui that referenced this pull request May 19, 2026
…Copilot nesquena#5)

manual_repro_wakeup_hang.py sat in the pytest test root with a
hardcoded /home/islaliu/.hermes/... venv path in its docstring.
pytest never ran it (no test_ functions) but it was confusing.

- git mv tests/manual_repro_wakeup_hang.py -> tests/manual/repro_wakeup_hang.py
- Strip the operator-specific absolute interpreter path from the
  docstring; the run instruction is now parametric ("python
  tests/manual/repro_wakeup_hang.py" from repo root) and fix the
  sys.path repo-root math for the now-deeper location.
- Exclude tests/manual/ from collection via collect_ignore_glob in the
  existing tests/conftest.py rather than a nested conftest.py: a second
  conftest module would collide with the suite's top-level
  'from conftest import TEST_BASE/TEST_STATE_DIR' imports (no packages
  / rootdir-relative module names).

Verified: pytest --collect-only tests/ collects 6014 tests, 0
collection errors, the manual script no longer surfaces.
Isla-Liu pushed a commit to Isla-Liu/hermes-webui that referenced this pull request May 19, 2026
…esquena#5 follow-up)

Copilot review nesquena#5 'strip local paths' missed one residue:
tests/test_wakeup_model_resolve_hang.py:3-4 still embedded the
reviewer's machine path /home/islaliu/.hermes/webui/bootstrap-8787.log.

Rewrote lines 3-4 to a neutral description ('a live thread-stack
capture of a hung wakeup turn') — root-cause explanation itself
unchanged. Full re-grep of tests/ + tests/manual/ confirms this was
the only remaining residue. 7/7 tests pass.
Isla-Liu pushed a commit to Isla-Liu/hermes-webui that referenced this pull request May 19, 2026
…Copilot nesquena#5)

manual_repro_wakeup_hang.py sat in the pytest test root with a
hardcoded /home/islaliu/.hermes/... venv path in its docstring.
pytest never ran it (no test_ functions) but it was confusing.

- git mv tests/manual_repro_wakeup_hang.py -> tests/manual/repro_wakeup_hang.py
- Strip the operator-specific absolute interpreter path from the
  docstring; the run instruction is now parametric ("python
  tests/manual/repro_wakeup_hang.py" from repo root) and fix the
  sys.path repo-root math for the now-deeper location.
- Exclude tests/manual/ from collection via collect_ignore_glob in the
  existing tests/conftest.py rather than a nested conftest.py: a second
  conftest module would collide with the suite's top-level
  'from conftest import TEST_BASE/TEST_STATE_DIR' imports (no packages
  / rootdir-relative module names).

Verified: pytest --collect-only tests/ collects 6014 tests, 0
collection errors, the manual script no longer surfaces.
Isla-Liu pushed a commit to Isla-Liu/hermes-webui that referenced this pull request May 19, 2026
…esquena#5 follow-up)

Copilot review nesquena#5 'strip local paths' missed one residue:
tests/test_wakeup_model_resolve_hang.py:3-4 still embedded the
reviewer's machine path /home/islaliu/.hermes/webui/bootstrap-8787.log.

Rewrote lines 3-4 to a neutral description ('a live thread-stack
capture of a hung wakeup turn') — root-cause explanation itself
unchanged. Full re-grep of tests/ + tests/manual/ confirms this was
the only remaining residue. 7/7 tests pass.
Isla-Liu pushed a commit to Isla-Liu/hermes-webui that referenced this pull request May 21, 2026
…Copilot nesquena#5)

manual_repro_wakeup_hang.py sat in the pytest test root with a
hardcoded /home/islaliu/.hermes/... venv path in its docstring.
pytest never ran it (no test_ functions) but it was confusing.

- git mv tests/manual_repro_wakeup_hang.py -> tests/manual/repro_wakeup_hang.py
- Strip the operator-specific absolute interpreter path from the
  docstring; the run instruction is now parametric ("python
  tests/manual/repro_wakeup_hang.py" from repo root) and fix the
  sys.path repo-root math for the now-deeper location.
- Exclude tests/manual/ from collection via collect_ignore_glob in the
  existing tests/conftest.py rather than a nested conftest.py: a second
  conftest module would collide with the suite's top-level
  'from conftest import TEST_BASE/TEST_STATE_DIR' imports (no packages
  / rootdir-relative module names).

Verified: pytest --collect-only tests/ collects 6014 tests, 0
collection errors, the manual script no longer surfaces.
Isla-Liu pushed a commit to Isla-Liu/hermes-webui that referenced this pull request May 21, 2026
…esquena#5 follow-up)

Copilot review nesquena#5 'strip local paths' missed one residue:
tests/test_wakeup_model_resolve_hang.py:3-4 still embedded the
reviewer's machine path /home/islaliu/.hermes/webui/bootstrap-8787.log.

Rewrote lines 3-4 to a neutral description ('a live thread-stack
capture of a hung wakeup turn') — root-cause explanation itself
unchanged. Full re-grep of tests/ + tests/manual/ confirms this was
the only remaining residue. 7/7 tests pass.
Isla-Liu added a commit to Isla-Liu/hermes-webui that referenced this pull request May 22, 2026
…Copilot nesquena#5)

manual_repro_wakeup_hang.py sat in the pytest test root with a
hardcoded /home/islaliu/.hermes/... venv path in its docstring.
pytest never ran it (no test_ functions) but it was confusing.

- git mv tests/manual_repro_wakeup_hang.py -> tests/manual/repro_wakeup_hang.py
- Strip the operator-specific absolute interpreter path from the
  docstring; the run instruction is now parametric ("python
  tests/manual/repro_wakeup_hang.py" from repo root) and fix the
  sys.path repo-root math for the now-deeper location.
- Exclude tests/manual/ from collection via collect_ignore_glob in the
  existing tests/conftest.py rather than a nested conftest.py: a second
  conftest module would collide with the suite's top-level
  'from conftest import TEST_BASE/TEST_STATE_DIR' imports (no packages
  / rootdir-relative module names).

Verified: pytest --collect-only tests/ collects 6014 tests, 0
collection errors, the manual script no longer surfaces.
Isla-Liu added a commit to Isla-Liu/hermes-webui that referenced this pull request May 22, 2026
…esquena#5 follow-up)

Copilot review nesquena#5 'strip local paths' missed one residue:
tests/test_wakeup_model_resolve_hang.py:3-4 still embedded the
reviewer's machine path /home/islaliu/.hermes/webui/bootstrap-8787.log.

Rewrote lines 3-4 to a neutral description ('a live thread-stack
capture of a hung wakeup turn') — root-cause explanation itself
unchanged. Full re-grep of tests/ + tests/manual/ confirms this was
the only remaining residue. 7/7 tests pass.
elderbernardi added a commit to elderbernardi/hermes-webui that referenced this pull request Jul 27, 2026
…quena#2), HITL dedup (nesquena#5), _prime in try (nesquena#6) — whole-branch review fix wave
bernyforce pushed a commit to bernyforce/hermes-webui that referenced this pull request Jul 29, 2026
SHOULD-FIX nesquena#1 (renamed-root client cross-alias): drop strict-equality client
filter at static/sessions.js:1853. Server-side _profiles_match cross-aliases
'default'-tagged rows to a renamed root 'kinni'; the strict-equality client
would reject them, dropping every legacy session for renamed-root users. The
server is now solely authoritative for profile scoping.

SHOULD-FIX nesquena#2 (messaging-source dedupe ordering): _keep_latest_messaging_session_per_source
now runs AFTER the profile filter at api/routes.py:2078. Before, it ran on
the merged-cross-profile list with profile-blind keys, discarding the older
profile's row across profiles before the scope filter — leaving zero rows for
any messaging identity the active profile shared with another profile.

NIT nesquena#3: _projects_migrated flag now set only AFTER successful save_projects.
NIT nesquena#4: cleaned dead test code in test_is_root_profile_invalidation_drops_stale.
NIT nesquena#5: _create_profile_fallback's clone_from=='default' literal now routes
through _is_root_profile() for parity with the 5 other callsites.

+2 regression tests pin the SHOULD-FIX shapes:
- test_keep_latest_messaging_runs_after_profile_filter (source-string ordering)
- test_static_sessions_js_trusts_server_profile_scoping (no client re-filter)

4173 -> 4175 tests pass. 0 regressions.
bernyforce pushed a commit to bernyforce/hermes-webui that referenced this pull request Jul 29, 2026
Inline fixes for 4 of 5 Opus SHOULD-FIX items before tag:

1. /api/auth/status now gates passkeys_enabled / passwordless_enabled on
   _passkey_feature_flag_enabled() — when flag is off, status reports
   no credentials even if passkeys.json has legacy entries. New
   passkey_feature_flag field added to the response for the frontend.

2. Settings → System Passkeys block (passkeysSettingsBlock) now starts
   display:none and loadPasskeys() reveals it only when the server
   confirms passkey_feature_flag === true AND /api/auth/passkeys
   doesn't return {disabled: true}. Stops the broken-affordance trap
   where users would see Add passkey → click → 404.

3. /api/settings/save now refuses to set passwordless mode when the
   passkey feature flag is off. Closes the auth-bypass path Opus flagged:
   user goes passwordless while flag on → admin unsets flag → restart
   serves the WebUI fully unauthenticated.

4. CHANGELOG entries added for PR nesquena#2685 (replayed-context dedup +
   per-turn metering cap) and PR nesquena#2824 (Stop server affordance,
   relocated to Settings) — both PRs had functional changes but no
   release-notes entries. Also enriched the rate-limit detail on the
   nesquena#2739 entry (30 events / 60s / 4KB body cap).

Deferred to follow-up issue (nesquena#5 in Opus review):
- Live tool metering cumulative cap across many tool calls — non-trivial
  refactor of _bump_live_prompt_estimate, will be a separate PR
rh-id added a commit to rh-id/hermes-webui that referenced this pull request Aug 2, 2026
…#6139 r8)

Round-8 re-gate (2026-07-31T12:10:36Z): one logical tail read still mixed
independently-reopened pathname generations. _read_jsonl_tail opened the
journal once, read the tail, closed the descriptor, then the recovery
helpers each independently reopened the pathname (path.stat + path.open):
_find_record_start_before, _extract_boundary_record_summary,
_record_is_structurally_complete, _read_last_complete_line_before, and the
head-newline counter. A delete-and-recreate between stages could combine
inode-A tail rows/offsets with inode-B boundary/predecessor rows, producing
impossible mixed sequences like [100, 2].

## Fix: thread ONE descriptor + pinned fstat size

_read_jsonl_tail now opens the journal ONCE (fh = path.open('rb')), pins
the size with os.fstat(fh.fileno()).st_size, and passes that same fh + size
through every recovery stage. A try/finally ensures the descriptor is
released on every exit (success/error/early-return). The single top-level
except (FileNotFoundError, OSError) catches a delete during the open; once
open, the handle is stable and all stages read one generation.

All 5 private helpers converted from (path: Path, ...) to (fh, size, ...):
  _find_record_start_before(fh, size, seek_pos)
  _rfind_byte_before(fh, byte, end_offset)
  _record_is_structurally_complete(fh, size, record_start)
  _extract_boundary_record_summary(fh, record_start)
  _read_last_complete_line_before(fh, size, end_offset, *, budget=None)
Each dropped its own path.stat()/path.open() and reads via the passed fh
against the pinned size. No path.stat/path.open remains in any helper.

## Point nesquena#4: explicit aggregate budget for the backward scan

_read_last_complete_line_before gains a budget keyword (default
_SESSION_REPLAY_MAX_BYTES). The loop tracks bytes_consumed and rows_scanned;
when either exceeds the budget / _SESSION_REPLAY_MAX_ROWS, it stops and
returns None. This bounds the scan across an arbitrarily long invalid-row
streak (it can no longer walk to byte 0).

## Point nesquena#5: don't trust oversized-malformed fabricated prefix

In _read_last_complete_line_before, the oversized branch now requires
_record_is_structurally_complete(fh, size, line_start) to be True before
accepting _extract_boundary_record_summary's fabricated prefix. An oversized
row that is structurally incomplete (brace depth never returns to 0) is
treated like any other malformed line: skipped, scan continues. This closes
the hole where an oversized malformed predecessor's head could fabricate a
terminal prefix as if the whole row were valid JSON.

## Regressions (4 new, mutation-verified)

- test_tail_read_uses_single_generation_under_delete_recreate: counter on
  Path.open — 1st open returns inode-A (token seq=1 + oversized partial),
  2nd+ returns inode-B (done seq=100 terminal). Asserts the journal is
  opened exactly ONCE and the result is purely inode-A (no seq=100, no
  terminal leak). Mutation A (reopen for boundary summary) FAILS it.
- test_invalid_predecessor_rows_skipped_until_valid_event: reader-level
  coverage of malformed JSON / JSON scalar / JSON list / blank / mixed
  rows before an oversized boundary; both readers recover the token
  (seq=1) + emit apperror. Mutation B (budget=0) FAILS it.
- test_backward_predecessor_scan_budget_bounds_the_scan: a tight budget
  must STOP before reaching a token past 500 malformed rows (None), a
  generous budget reaches it (seq=1). Mutation B (budget=0) FAILS it.
- test_oversized_malformed_predecessor_not_accepted_via_fabricated_prefix:
  an oversized structurally-incomplete predecessor (newline-terminated
  but brace-broken) is skipped via the gate; the valid token (seq=1) is
  recovered, never the fabricated terminal done. Mutation C (gate
  removed) FAILS it.

Existing ~6 test call-sites that called helpers by path updated to the new
(fh, size, ...) signature (open once + os.fstat). The 3 TOCTOU tests
reworked to exercise the single-open TOCTOU (first open raises).

43/43 pass; lint diff-gate clean; git diff --check clean. Round-6
terminator hardening, _read_jsonl (full read), the summary cache, and
routes.py are all unchanged.
rh-id added a commit to rh-id/hermes-webui that referenced this pull request Aug 2, 2026
…#6139 r8)

Round-8 re-gate (2026-07-31T12:10:36Z): one logical tail read still mixed
independently-reopened pathname generations. _read_jsonl_tail opened the
journal once, read the tail, closed the descriptor, then the recovery
helpers each independently reopened the pathname (path.stat + path.open):
_find_record_start_before, _extract_boundary_record_summary,
_record_is_structurally_complete, _read_last_complete_line_before, and the
head-newline counter. A delete-and-recreate between stages could combine
inode-A tail rows/offsets with inode-B boundary/predecessor rows, producing
impossible mixed sequences like [100, 2].

## Fix: thread ONE descriptor + pinned fstat size

_read_jsonl_tail now opens the journal ONCE (fh = path.open('rb')), pins
the size with os.fstat(fh.fileno()).st_size, and passes that same fh + size
through every recovery stage. A try/finally ensures the descriptor is
released on every exit (success/error/early-return). The single top-level
except (FileNotFoundError, OSError) catches a delete during the open; once
open, the handle is stable and all stages read one generation.

All 5 private helpers converted from (path: Path, ...) to (fh, size, ...):
  _find_record_start_before(fh, size, seek_pos)
  _rfind_byte_before(fh, byte, end_offset)
  _record_is_structurally_complete(fh, size, record_start)
  _extract_boundary_record_summary(fh, record_start)
  _read_last_complete_line_before(fh, size, end_offset, *, budget=None)
Each dropped its own path.stat()/path.open() and reads via the passed fh
against the pinned size. No path.stat/path.open remains in any helper.

## Point nesquena#4: explicit aggregate budget for the backward scan

_read_last_complete_line_before gains a budget keyword (default
_SESSION_REPLAY_MAX_BYTES). The loop tracks bytes_consumed and rows_scanned;
when either exceeds the budget / _SESSION_REPLAY_MAX_ROWS, it stops and
returns None. This bounds the scan across an arbitrarily long invalid-row
streak (it can no longer walk to byte 0).

## Point nesquena#5: don't trust oversized-malformed fabricated prefix

In _read_last_complete_line_before, the oversized branch now requires
_record_is_structurally_complete(fh, size, line_start) to be True before
accepting _extract_boundary_record_summary's fabricated prefix. An oversized
row that is structurally incomplete (brace depth never returns to 0) is
treated like any other malformed line: skipped, scan continues. This closes
the hole where an oversized malformed predecessor's head could fabricate a
terminal prefix as if the whole row were valid JSON.

## Regressions (4 new, mutation-verified)

- test_tail_read_uses_single_generation_under_delete_recreate: counter on
  Path.open — 1st open returns inode-A (token seq=1 + oversized partial),
  2nd+ returns inode-B (done seq=100 terminal). Asserts the journal is
  opened exactly ONCE and the result is purely inode-A (no seq=100, no
  terminal leak). Mutation A (reopen for boundary summary) FAILS it.
- test_invalid_predecessor_rows_skipped_until_valid_event: reader-level
  coverage of malformed JSON / JSON scalar / JSON list / blank / mixed
  rows before an oversized boundary; both readers recover the token
  (seq=1) + emit apperror. Mutation B (budget=0) FAILS it.
- test_backward_predecessor_scan_budget_bounds_the_scan: a tight budget
  must STOP before reaching a token past 500 malformed rows (None), a
  generous budget reaches it (seq=1). Mutation B (budget=0) FAILS it.
- test_oversized_malformed_predecessor_not_accepted_via_fabricated_prefix:
  an oversized structurally-incomplete predecessor (newline-terminated
  but brace-broken) is skipped via the gate; the valid token (seq=1) is
  recovered, never the fabricated terminal done. Mutation C (gate
  removed) FAILS it.

Existing ~6 test call-sites that called helpers by path updated to the new
(fh, size, ...) signature (open once + os.fstat). The 3 TOCTOU tests
reworked to exercise the single-open TOCTOU (first open raises).

43/43 pass; lint diff-gate clean; git diff --check clean. Round-6
terminator hardening, _read_jsonl (full read), the summary cache, and
routes.py are all unchanged.
rh-id added a commit to rh-id/hermes-webui that referenced this pull request Aug 9, 2026
…#6139 r8)

Round-8 re-gate (2026-07-31T12:10:36Z): one logical tail read still mixed
independently-reopened pathname generations. _read_jsonl_tail opened the
journal once, read the tail, closed the descriptor, then the recovery
helpers each independently reopened the pathname (path.stat + path.open):
_find_record_start_before, _extract_boundary_record_summary,
_record_is_structurally_complete, _read_last_complete_line_before, and the
head-newline counter. A delete-and-recreate between stages could combine
inode-A tail rows/offsets with inode-B boundary/predecessor rows, producing
impossible mixed sequences like [100, 2].

## Fix: thread ONE descriptor + pinned fstat size

_read_jsonl_tail now opens the journal ONCE (fh = path.open('rb')), pins
the size with os.fstat(fh.fileno()).st_size, and passes that same fh + size
through every recovery stage. A try/finally ensures the descriptor is
released on every exit (success/error/early-return). The single top-level
except (FileNotFoundError, OSError) catches a delete during the open; once
open, the handle is stable and all stages read one generation.

All 5 private helpers converted from (path: Path, ...) to (fh, size, ...):
  _find_record_start_before(fh, size, seek_pos)
  _rfind_byte_before(fh, byte, end_offset)
  _record_is_structurally_complete(fh, size, record_start)
  _extract_boundary_record_summary(fh, record_start)
  _read_last_complete_line_before(fh, size, end_offset, *, budget=None)
Each dropped its own path.stat()/path.open() and reads via the passed fh
against the pinned size. No path.stat/path.open remains in any helper.

## Point nesquena#4: explicit aggregate budget for the backward scan

_read_last_complete_line_before gains a budget keyword (default
_SESSION_REPLAY_MAX_BYTES). The loop tracks bytes_consumed and rows_scanned;
when either exceeds the budget / _SESSION_REPLAY_MAX_ROWS, it stops and
returns None. This bounds the scan across an arbitrarily long invalid-row
streak (it can no longer walk to byte 0).

## Point nesquena#5: don't trust oversized-malformed fabricated prefix

In _read_last_complete_line_before, the oversized branch now requires
_record_is_structurally_complete(fh, size, line_start) to be True before
accepting _extract_boundary_record_summary's fabricated prefix. An oversized
row that is structurally incomplete (brace depth never returns to 0) is
treated like any other malformed line: skipped, scan continues. This closes
the hole where an oversized malformed predecessor's head could fabricate a
terminal prefix as if the whole row were valid JSON.

## Regressions (4 new, mutation-verified)

- test_tail_read_uses_single_generation_under_delete_recreate: counter on
  Path.open — 1st open returns inode-A (token seq=1 + oversized partial),
  2nd+ returns inode-B (done seq=100 terminal). Asserts the journal is
  opened exactly ONCE and the result is purely inode-A (no seq=100, no
  terminal leak). Mutation A (reopen for boundary summary) FAILS it.
- test_invalid_predecessor_rows_skipped_until_valid_event: reader-level
  coverage of malformed JSON / JSON scalar / JSON list / blank / mixed
  rows before an oversized boundary; both readers recover the token
  (seq=1) + emit apperror. Mutation B (budget=0) FAILS it.
- test_backward_predecessor_scan_budget_bounds_the_scan: a tight budget
  must STOP before reaching a token past 500 malformed rows (None), a
  generous budget reaches it (seq=1). Mutation B (budget=0) FAILS it.
- test_oversized_malformed_predecessor_not_accepted_via_fabricated_prefix:
  an oversized structurally-incomplete predecessor (newline-terminated
  but brace-broken) is skipped via the gate; the valid token (seq=1) is
  recovered, never the fabricated terminal done. Mutation C (gate
  removed) FAILS it.

Existing ~6 test call-sites that called helpers by path updated to the new
(fh, size, ...) signature (open once + os.fstat). The 3 TOCTOU tests
reworked to exercise the single-open TOCTOU (first open raises).

43/43 pass; lint diff-gate clean; git diff --check clean. Round-6
terminator hardening, _read_jsonl (full read), the summary cache, and
routes.py are all unchanged.
rh-id added a commit to rh-id/hermes-webui that referenced this pull request Aug 10, 2026
…#6139 r8)

Round-8 re-gate (2026-07-31T12:10:36Z): one logical tail read still mixed
independently-reopened pathname generations. _read_jsonl_tail opened the
journal once, read the tail, closed the descriptor, then the recovery
helpers each independently reopened the pathname (path.stat + path.open):
_find_record_start_before, _extract_boundary_record_summary,
_record_is_structurally_complete, _read_last_complete_line_before, and the
head-newline counter. A delete-and-recreate between stages could combine
inode-A tail rows/offsets with inode-B boundary/predecessor rows, producing
impossible mixed sequences like [100, 2].

## Fix: thread ONE descriptor + pinned fstat size

_read_jsonl_tail now opens the journal ONCE (fh = path.open('rb')), pins
the size with os.fstat(fh.fileno()).st_size, and passes that same fh + size
through every recovery stage. A try/finally ensures the descriptor is
released on every exit (success/error/early-return). The single top-level
except (FileNotFoundError, OSError) catches a delete during the open; once
open, the handle is stable and all stages read one generation.

All 5 private helpers converted from (path: Path, ...) to (fh, size, ...):
  _find_record_start_before(fh, size, seek_pos)
  _rfind_byte_before(fh, byte, end_offset)
  _record_is_structurally_complete(fh, size, record_start)
  _extract_boundary_record_summary(fh, record_start)
  _read_last_complete_line_before(fh, size, end_offset, *, budget=None)
Each dropped its own path.stat()/path.open() and reads via the passed fh
against the pinned size. No path.stat/path.open remains in any helper.

## Point nesquena#4: explicit aggregate budget for the backward scan

_read_last_complete_line_before gains a budget keyword (default
_SESSION_REPLAY_MAX_BYTES). The loop tracks bytes_consumed and rows_scanned;
when either exceeds the budget / _SESSION_REPLAY_MAX_ROWS, it stops and
returns None. This bounds the scan across an arbitrarily long invalid-row
streak (it can no longer walk to byte 0).

## Point nesquena#5: don't trust oversized-malformed fabricated prefix

In _read_last_complete_line_before, the oversized branch now requires
_record_is_structurally_complete(fh, size, line_start) to be True before
accepting _extract_boundary_record_summary's fabricated prefix. An oversized
row that is structurally incomplete (brace depth never returns to 0) is
treated like any other malformed line: skipped, scan continues. This closes
the hole where an oversized malformed predecessor's head could fabricate a
terminal prefix as if the whole row were valid JSON.

## Regressions (4 new, mutation-verified)

- test_tail_read_uses_single_generation_under_delete_recreate: counter on
  Path.open — 1st open returns inode-A (token seq=1 + oversized partial),
  2nd+ returns inode-B (done seq=100 terminal). Asserts the journal is
  opened exactly ONCE and the result is purely inode-A (no seq=100, no
  terminal leak). Mutation A (reopen for boundary summary) FAILS it.
- test_invalid_predecessor_rows_skipped_until_valid_event: reader-level
  coverage of malformed JSON / JSON scalar / JSON list / blank / mixed
  rows before an oversized boundary; both readers recover the token
  (seq=1) + emit apperror. Mutation B (budget=0) FAILS it.
- test_backward_predecessor_scan_budget_bounds_the_scan: a tight budget
  must STOP before reaching a token past 500 malformed rows (None), a
  generous budget reaches it (seq=1). Mutation B (budget=0) FAILS it.
- test_oversized_malformed_predecessor_not_accepted_via_fabricated_prefix:
  an oversized structurally-incomplete predecessor (newline-terminated
  but brace-broken) is skipped via the gate; the valid token (seq=1) is
  recovered, never the fabricated terminal done. Mutation C (gate
  removed) FAILS it.

Existing ~6 test call-sites that called helpers by path updated to the new
(fh, size, ...) signature (open once + os.fstat). The 3 TOCTOU tests
reworked to exercise the single-open TOCTOU (first open raises).

43/43 pass; lint diff-gate clean; git diff --check clean. Round-6
terminator hardening, _read_jsonl (full read), the summary cache, and
routes.py are all unchanged.
rh-id added a commit to rh-id/hermes-webui that referenced this pull request Aug 11, 2026
…#6139 r8)

Round-8 re-gate (2026-07-31T12:10:36Z): one logical tail read still mixed
independently-reopened pathname generations. _read_jsonl_tail opened the
journal once, read the tail, closed the descriptor, then the recovery
helpers each independently reopened the pathname (path.stat + path.open):
_find_record_start_before, _extract_boundary_record_summary,
_record_is_structurally_complete, _read_last_complete_line_before, and the
head-newline counter. A delete-and-recreate between stages could combine
inode-A tail rows/offsets with inode-B boundary/predecessor rows, producing
impossible mixed sequences like [100, 2].

## Fix: thread ONE descriptor + pinned fstat size

_read_jsonl_tail now opens the journal ONCE (fh = path.open('rb')), pins
the size with os.fstat(fh.fileno()).st_size, and passes that same fh + size
through every recovery stage. A try/finally ensures the descriptor is
released on every exit (success/error/early-return). The single top-level
except (FileNotFoundError, OSError) catches a delete during the open; once
open, the handle is stable and all stages read one generation.

All 5 private helpers converted from (path: Path, ...) to (fh, size, ...):
  _find_record_start_before(fh, size, seek_pos)
  _rfind_byte_before(fh, byte, end_offset)
  _record_is_structurally_complete(fh, size, record_start)
  _extract_boundary_record_summary(fh, record_start)
  _read_last_complete_line_before(fh, size, end_offset, *, budget=None)
Each dropped its own path.stat()/path.open() and reads via the passed fh
against the pinned size. No path.stat/path.open remains in any helper.

## Point nesquena#4: explicit aggregate budget for the backward scan

_read_last_complete_line_before gains a budget keyword (default
_SESSION_REPLAY_MAX_BYTES). The loop tracks bytes_consumed and rows_scanned;
when either exceeds the budget / _SESSION_REPLAY_MAX_ROWS, it stops and
returns None. This bounds the scan across an arbitrarily long invalid-row
streak (it can no longer walk to byte 0).

## Point nesquena#5: don't trust oversized-malformed fabricated prefix

In _read_last_complete_line_before, the oversized branch now requires
_record_is_structurally_complete(fh, size, line_start) to be True before
accepting _extract_boundary_record_summary's fabricated prefix. An oversized
row that is structurally incomplete (brace depth never returns to 0) is
treated like any other malformed line: skipped, scan continues. This closes
the hole where an oversized malformed predecessor's head could fabricate a
terminal prefix as if the whole row were valid JSON.

## Regressions (4 new, mutation-verified)

- test_tail_read_uses_single_generation_under_delete_recreate: counter on
  Path.open — 1st open returns inode-A (token seq=1 + oversized partial),
  2nd+ returns inode-B (done seq=100 terminal). Asserts the journal is
  opened exactly ONCE and the result is purely inode-A (no seq=100, no
  terminal leak). Mutation A (reopen for boundary summary) FAILS it.
- test_invalid_predecessor_rows_skipped_until_valid_event: reader-level
  coverage of malformed JSON / JSON scalar / JSON list / blank / mixed
  rows before an oversized boundary; both readers recover the token
  (seq=1) + emit apperror. Mutation B (budget=0) FAILS it.
- test_backward_predecessor_scan_budget_bounds_the_scan: a tight budget
  must STOP before reaching a token past 500 malformed rows (None), a
  generous budget reaches it (seq=1). Mutation B (budget=0) FAILS it.
- test_oversized_malformed_predecessor_not_accepted_via_fabricated_prefix:
  an oversized structurally-incomplete predecessor (newline-terminated
  but brace-broken) is skipped via the gate; the valid token (seq=1) is
  recovered, never the fabricated terminal done. Mutation C (gate
  removed) FAILS it.

Existing ~6 test call-sites that called helpers by path updated to the new
(fh, size, ...) signature (open once + os.fstat). The 3 TOCTOU tests
reworked to exercise the single-open TOCTOU (first open raises).

43/43 pass; lint diff-gate clean; git diff --check clean. Round-6
terminator hardening, _read_jsonl (full read), the summary cache, and
routes.py are all unchanged.
rh-id added a commit to rh-id/hermes-webui that referenced this pull request Aug 11, 2026
…#6139 r8)

Round-8 re-gate (2026-07-31T12:10:36Z): one logical tail read still mixed
independently-reopened pathname generations. _read_jsonl_tail opened the
journal once, read the tail, closed the descriptor, then the recovery
helpers each independently reopened the pathname (path.stat + path.open):
_find_record_start_before, _extract_boundary_record_summary,
_record_is_structurally_complete, _read_last_complete_line_before, and the
head-newline counter. A delete-and-recreate between stages could combine
inode-A tail rows/offsets with inode-B boundary/predecessor rows, producing
impossible mixed sequences like [100, 2].

## Fix: thread ONE descriptor + pinned fstat size

_read_jsonl_tail now opens the journal ONCE (fh = path.open('rb')), pins
the size with os.fstat(fh.fileno()).st_size, and passes that same fh + size
through every recovery stage. A try/finally ensures the descriptor is
released on every exit (success/error/early-return). The single top-level
except (FileNotFoundError, OSError) catches a delete during the open; once
open, the handle is stable and all stages read one generation.

All 5 private helpers converted from (path: Path, ...) to (fh, size, ...):
  _find_record_start_before(fh, size, seek_pos)
  _rfind_byte_before(fh, byte, end_offset)
  _record_is_structurally_complete(fh, size, record_start)
  _extract_boundary_record_summary(fh, record_start)
  _read_last_complete_line_before(fh, size, end_offset, *, budget=None)
Each dropped its own path.stat()/path.open() and reads via the passed fh
against the pinned size. No path.stat/path.open remains in any helper.

## Point nesquena#4: explicit aggregate budget for the backward scan

_read_last_complete_line_before gains a budget keyword (default
_SESSION_REPLAY_MAX_BYTES). The loop tracks bytes_consumed and rows_scanned;
when either exceeds the budget / _SESSION_REPLAY_MAX_ROWS, it stops and
returns None. This bounds the scan across an arbitrarily long invalid-row
streak (it can no longer walk to byte 0).

## Point nesquena#5: don't trust oversized-malformed fabricated prefix

In _read_last_complete_line_before, the oversized branch now requires
_record_is_structurally_complete(fh, size, line_start) to be True before
accepting _extract_boundary_record_summary's fabricated prefix. An oversized
row that is structurally incomplete (brace depth never returns to 0) is
treated like any other malformed line: skipped, scan continues. This closes
the hole where an oversized malformed predecessor's head could fabricate a
terminal prefix as if the whole row were valid JSON.

## Regressions (4 new, mutation-verified)

- test_tail_read_uses_single_generation_under_delete_recreate: counter on
  Path.open — 1st open returns inode-A (token seq=1 + oversized partial),
  2nd+ returns inode-B (done seq=100 terminal). Asserts the journal is
  opened exactly ONCE and the result is purely inode-A (no seq=100, no
  terminal leak). Mutation A (reopen for boundary summary) FAILS it.
- test_invalid_predecessor_rows_skipped_until_valid_event: reader-level
  coverage of malformed JSON / JSON scalar / JSON list / blank / mixed
  rows before an oversized boundary; both readers recover the token
  (seq=1) + emit apperror. Mutation B (budget=0) FAILS it.
- test_backward_predecessor_scan_budget_bounds_the_scan: a tight budget
  must STOP before reaching a token past 500 malformed rows (None), a
  generous budget reaches it (seq=1). Mutation B (budget=0) FAILS it.
- test_oversized_malformed_predecessor_not_accepted_via_fabricated_prefix:
  an oversized structurally-incomplete predecessor (newline-terminated
  but brace-broken) is skipped via the gate; the valid token (seq=1) is
  recovered, never the fabricated terminal done. Mutation C (gate
  removed) FAILS it.

Existing ~6 test call-sites that called helpers by path updated to the new
(fh, size, ...) signature (open once + os.fstat). The 3 TOCTOU tests
reworked to exercise the single-open TOCTOU (first open raises).

43/43 pass; lint diff-gate clean; git diff --check clean. Round-6
terminator hardening, _read_jsonl (full read), the summary cache, and
routes.py are all unchanged.
rh-id added a commit to rh-id/hermes-webui that referenced this pull request Aug 12, 2026
…#6139 r8)

Round-8 re-gate (2026-07-31T12:10:36Z): one logical tail read still mixed
independently-reopened pathname generations. _read_jsonl_tail opened the
journal once, read the tail, closed the descriptor, then the recovery
helpers each independently reopened the pathname (path.stat + path.open):
_find_record_start_before, _extract_boundary_record_summary,
_record_is_structurally_complete, _read_last_complete_line_before, and the
head-newline counter. A delete-and-recreate between stages could combine
inode-A tail rows/offsets with inode-B boundary/predecessor rows, producing
impossible mixed sequences like [100, 2].

## Fix: thread ONE descriptor + pinned fstat size

_read_jsonl_tail now opens the journal ONCE (fh = path.open('rb')), pins
the size with os.fstat(fh.fileno()).st_size, and passes that same fh + size
through every recovery stage. A try/finally ensures the descriptor is
released on every exit (success/error/early-return). The single top-level
except (FileNotFoundError, OSError) catches a delete during the open; once
open, the handle is stable and all stages read one generation.

All 5 private helpers converted from (path: Path, ...) to (fh, size, ...):
  _find_record_start_before(fh, size, seek_pos)
  _rfind_byte_before(fh, byte, end_offset)
  _record_is_structurally_complete(fh, size, record_start)
  _extract_boundary_record_summary(fh, record_start)
  _read_last_complete_line_before(fh, size, end_offset, *, budget=None)
Each dropped its own path.stat()/path.open() and reads via the passed fh
against the pinned size. No path.stat/path.open remains in any helper.

## Point nesquena#4: explicit aggregate budget for the backward scan

_read_last_complete_line_before gains a budget keyword (default
_SESSION_REPLAY_MAX_BYTES). The loop tracks bytes_consumed and rows_scanned;
when either exceeds the budget / _SESSION_REPLAY_MAX_ROWS, it stops and
returns None. This bounds the scan across an arbitrarily long invalid-row
streak (it can no longer walk to byte 0).

## Point nesquena#5: don't trust oversized-malformed fabricated prefix

In _read_last_complete_line_before, the oversized branch now requires
_record_is_structurally_complete(fh, size, line_start) to be True before
accepting _extract_boundary_record_summary's fabricated prefix. An oversized
row that is structurally incomplete (brace depth never returns to 0) is
treated like any other malformed line: skipped, scan continues. This closes
the hole where an oversized malformed predecessor's head could fabricate a
terminal prefix as if the whole row were valid JSON.

## Regressions (4 new, mutation-verified)

- test_tail_read_uses_single_generation_under_delete_recreate: counter on
  Path.open — 1st open returns inode-A (token seq=1 + oversized partial),
  2nd+ returns inode-B (done seq=100 terminal). Asserts the journal is
  opened exactly ONCE and the result is purely inode-A (no seq=100, no
  terminal leak). Mutation A (reopen for boundary summary) FAILS it.
- test_invalid_predecessor_rows_skipped_until_valid_event: reader-level
  coverage of malformed JSON / JSON scalar / JSON list / blank / mixed
  rows before an oversized boundary; both readers recover the token
  (seq=1) + emit apperror. Mutation B (budget=0) FAILS it.
- test_backward_predecessor_scan_budget_bounds_the_scan: a tight budget
  must STOP before reaching a token past 500 malformed rows (None), a
  generous budget reaches it (seq=1). Mutation B (budget=0) FAILS it.
- test_oversized_malformed_predecessor_not_accepted_via_fabricated_prefix:
  an oversized structurally-incomplete predecessor (newline-terminated
  but brace-broken) is skipped via the gate; the valid token (seq=1) is
  recovered, never the fabricated terminal done. Mutation C (gate
  removed) FAILS it.

Existing ~6 test call-sites that called helpers by path updated to the new
(fh, size, ...) signature (open once + os.fstat). The 3 TOCTOU tests
reworked to exercise the single-open TOCTOU (first open raises).

43/43 pass; lint diff-gate clean; git diff --check clean. Round-6
terminator hardening, _read_jsonl (full read), the summary cache, and
routes.py are all unchanged.
rh-id added a commit to rh-id/hermes-webui that referenced this pull request Aug 12, 2026
…#6139 r8)

Round-8 re-gate (2026-07-31T12:10:36Z): one logical tail read still mixed
independently-reopened pathname generations. _read_jsonl_tail opened the
journal once, read the tail, closed the descriptor, then the recovery
helpers each independently reopened the pathname (path.stat + path.open):
_find_record_start_before, _extract_boundary_record_summary,
_record_is_structurally_complete, _read_last_complete_line_before, and the
head-newline counter. A delete-and-recreate between stages could combine
inode-A tail rows/offsets with inode-B boundary/predecessor rows, producing
impossible mixed sequences like [100, 2].

## Fix: thread ONE descriptor + pinned fstat size

_read_jsonl_tail now opens the journal ONCE (fh = path.open('rb')), pins
the size with os.fstat(fh.fileno()).st_size, and passes that same fh + size
through every recovery stage. A try/finally ensures the descriptor is
released on every exit (success/error/early-return). The single top-level
except (FileNotFoundError, OSError) catches a delete during the open; once
open, the handle is stable and all stages read one generation.

All 5 private helpers converted from (path: Path, ...) to (fh, size, ...):
  _find_record_start_before(fh, size, seek_pos)
  _rfind_byte_before(fh, byte, end_offset)
  _record_is_structurally_complete(fh, size, record_start)
  _extract_boundary_record_summary(fh, record_start)
  _read_last_complete_line_before(fh, size, end_offset, *, budget=None)
Each dropped its own path.stat()/path.open() and reads via the passed fh
against the pinned size. No path.stat/path.open remains in any helper.

## Point nesquena#4: explicit aggregate budget for the backward scan

_read_last_complete_line_before gains a budget keyword (default
_SESSION_REPLAY_MAX_BYTES). The loop tracks bytes_consumed and rows_scanned;
when either exceeds the budget / _SESSION_REPLAY_MAX_ROWS, it stops and
returns None. This bounds the scan across an arbitrarily long invalid-row
streak (it can no longer walk to byte 0).

## Point nesquena#5: don't trust oversized-malformed fabricated prefix

In _read_last_complete_line_before, the oversized branch now requires
_record_is_structurally_complete(fh, size, line_start) to be True before
accepting _extract_boundary_record_summary's fabricated prefix. An oversized
row that is structurally incomplete (brace depth never returns to 0) is
treated like any other malformed line: skipped, scan continues. This closes
the hole where an oversized malformed predecessor's head could fabricate a
terminal prefix as if the whole row were valid JSON.

## Regressions (4 new, mutation-verified)

- test_tail_read_uses_single_generation_under_delete_recreate: counter on
  Path.open — 1st open returns inode-A (token seq=1 + oversized partial),
  2nd+ returns inode-B (done seq=100 terminal). Asserts the journal is
  opened exactly ONCE and the result is purely inode-A (no seq=100, no
  terminal leak). Mutation A (reopen for boundary summary) FAILS it.
- test_invalid_predecessor_rows_skipped_until_valid_event: reader-level
  coverage of malformed JSON / JSON scalar / JSON list / blank / mixed
  rows before an oversized boundary; both readers recover the token
  (seq=1) + emit apperror. Mutation B (budget=0) FAILS it.
- test_backward_predecessor_scan_budget_bounds_the_scan: a tight budget
  must STOP before reaching a token past 500 malformed rows (None), a
  generous budget reaches it (seq=1). Mutation B (budget=0) FAILS it.
- test_oversized_malformed_predecessor_not_accepted_via_fabricated_prefix:
  an oversized structurally-incomplete predecessor (newline-terminated
  but brace-broken) is skipped via the gate; the valid token (seq=1) is
  recovered, never the fabricated terminal done. Mutation C (gate
  removed) FAILS it.

Existing ~6 test call-sites that called helpers by path updated to the new
(fh, size, ...) signature (open once + os.fstat). The 3 TOCTOU tests
reworked to exercise the single-open TOCTOU (first open raises).

43/43 pass; lint diff-gate clean; git diff --check clean. Round-6
terminator hardening, _read_jsonl (full read), the summary cache, and
routes.py are all unchanged.
webtecnica referenced this pull request in webtecnica/hermes-webui Aug 13, 2026
…SE relay (nesquena#6961)

Read/surface half of the maintainer's split for PR nesquena#6961 (child approval
routing, nesquena#6943). The resolve half (#1/#2/#3) stays in a follow-up gated on
the agent contract (agent#82009).

#4 (CORE): scope the child->parent cache by canonical state-db/profile path
and only cache positive lookups, so a miss under one profile can no longer
poison another profile's identical child id, and a late state.db write is
picked up on the next lookup. Adds invalidate_child_parent_cache().

#5 (SILENT): use one aggregate projection (own queue + delegated-child
queues, deduped by stable approval id / gateway mirror token) unconditionally
on all three surface paths — sidebar attention summary, /api/approval/pending,
and the SSE initial snapshot — so a parent-with-1 + child-with-1 now reports
count 2 instead of 1.

#6 (SILENT): publish the aggregate parent head/count to the parent's SSE
subscribers whenever an owned child queue changes (submit_pending,
submit_gateway_pending_mirror, retire_gateway_pending_mirror,
resolve_gateway_pending_local, resolve_child_approval_locked), so a pure-SSE
parent consumer sees child enqueue/resolve without waiting for the 1.5s poll.
rh-id added a commit to rh-id/hermes-webui that referenced this pull request Aug 13, 2026
…#6139 r8)

Round-8 re-gate (2026-07-31T12:10:36Z): one logical tail read still mixed
independently-reopened pathname generations. _read_jsonl_tail opened the
journal once, read the tail, closed the descriptor, then the recovery
helpers each independently reopened the pathname (path.stat + path.open):
_find_record_start_before, _extract_boundary_record_summary,
_record_is_structurally_complete, _read_last_complete_line_before, and the
head-newline counter. A delete-and-recreate between stages could combine
inode-A tail rows/offsets with inode-B boundary/predecessor rows, producing
impossible mixed sequences like [100, 2].

## Fix: thread ONE descriptor + pinned fstat size

_read_jsonl_tail now opens the journal ONCE (fh = path.open('rb')), pins
the size with os.fstat(fh.fileno()).st_size, and passes that same fh + size
through every recovery stage. A try/finally ensures the descriptor is
released on every exit (success/error/early-return). The single top-level
except (FileNotFoundError, OSError) catches a delete during the open; once
open, the handle is stable and all stages read one generation.

All 5 private helpers converted from (path: Path, ...) to (fh, size, ...):
  _find_record_start_before(fh, size, seek_pos)
  _rfind_byte_before(fh, byte, end_offset)
  _record_is_structurally_complete(fh, size, record_start)
  _extract_boundary_record_summary(fh, record_start)
  _read_last_complete_line_before(fh, size, end_offset, *, budget=None)
Each dropped its own path.stat()/path.open() and reads via the passed fh
against the pinned size. No path.stat/path.open remains in any helper.

## Point nesquena#4: explicit aggregate budget for the backward scan

_read_last_complete_line_before gains a budget keyword (default
_SESSION_REPLAY_MAX_BYTES). The loop tracks bytes_consumed and rows_scanned;
when either exceeds the budget / _SESSION_REPLAY_MAX_ROWS, it stops and
returns None. This bounds the scan across an arbitrarily long invalid-row
streak (it can no longer walk to byte 0).

## Point nesquena#5: don't trust oversized-malformed fabricated prefix

In _read_last_complete_line_before, the oversized branch now requires
_record_is_structurally_complete(fh, size, line_start) to be True before
accepting _extract_boundary_record_summary's fabricated prefix. An oversized
row that is structurally incomplete (brace depth never returns to 0) is
treated like any other malformed line: skipped, scan continues. This closes
the hole where an oversized malformed predecessor's head could fabricate a
terminal prefix as if the whole row were valid JSON.

## Regressions (4 new, mutation-verified)

- test_tail_read_uses_single_generation_under_delete_recreate: counter on
  Path.open — 1st open returns inode-A (token seq=1 + oversized partial),
  2nd+ returns inode-B (done seq=100 terminal). Asserts the journal is
  opened exactly ONCE and the result is purely inode-A (no seq=100, no
  terminal leak). Mutation A (reopen for boundary summary) FAILS it.
- test_invalid_predecessor_rows_skipped_until_valid_event: reader-level
  coverage of malformed JSON / JSON scalar / JSON list / blank / mixed
  rows before an oversized boundary; both readers recover the token
  (seq=1) + emit apperror. Mutation B (budget=0) FAILS it.
- test_backward_predecessor_scan_budget_bounds_the_scan: a tight budget
  must STOP before reaching a token past 500 malformed rows (None), a
  generous budget reaches it (seq=1). Mutation B (budget=0) FAILS it.
- test_oversized_malformed_predecessor_not_accepted_via_fabricated_prefix:
  an oversized structurally-incomplete predecessor (newline-terminated
  but brace-broken) is skipped via the gate; the valid token (seq=1) is
  recovered, never the fabricated terminal done. Mutation C (gate
  removed) FAILS it.

Existing ~6 test call-sites that called helpers by path updated to the new
(fh, size, ...) signature (open once + os.fstat). The 3 TOCTOU tests
reworked to exercise the single-open TOCTOU (first open raises).

43/43 pass; lint diff-gate clean; git diff --check clean. Round-6
terminator hardening, _read_jsonl (full read), the summary cache, and
routes.py are all unchanged.
rh-id added a commit to rh-id/hermes-webui that referenced this pull request Aug 13, 2026
…#6139 r8)

Round-8 re-gate (2026-07-31T12:10:36Z): one logical tail read still mixed
independently-reopened pathname generations. _read_jsonl_tail opened the
journal once, read the tail, closed the descriptor, then the recovery
helpers each independently reopened the pathname (path.stat + path.open):
_find_record_start_before, _extract_boundary_record_summary,
_record_is_structurally_complete, _read_last_complete_line_before, and the
head-newline counter. A delete-and-recreate between stages could combine
inode-A tail rows/offsets with inode-B boundary/predecessor rows, producing
impossible mixed sequences like [100, 2].

## Fix: thread ONE descriptor + pinned fstat size

_read_jsonl_tail now opens the journal ONCE (fh = path.open('rb')), pins
the size with os.fstat(fh.fileno()).st_size, and passes that same fh + size
through every recovery stage. A try/finally ensures the descriptor is
released on every exit (success/error/early-return). The single top-level
except (FileNotFoundError, OSError) catches a delete during the open; once
open, the handle is stable and all stages read one generation.

All 5 private helpers converted from (path: Path, ...) to (fh, size, ...):
  _find_record_start_before(fh, size, seek_pos)
  _rfind_byte_before(fh, byte, end_offset)
  _record_is_structurally_complete(fh, size, record_start)
  _extract_boundary_record_summary(fh, record_start)
  _read_last_complete_line_before(fh, size, end_offset, *, budget=None)
Each dropped its own path.stat()/path.open() and reads via the passed fh
against the pinned size. No path.stat/path.open remains in any helper.

## Point nesquena#4: explicit aggregate budget for the backward scan

_read_last_complete_line_before gains a budget keyword (default
_SESSION_REPLAY_MAX_BYTES). The loop tracks bytes_consumed and rows_scanned;
when either exceeds the budget / _SESSION_REPLAY_MAX_ROWS, it stops and
returns None. This bounds the scan across an arbitrarily long invalid-row
streak (it can no longer walk to byte 0).

## Point nesquena#5: don't trust oversized-malformed fabricated prefix

In _read_last_complete_line_before, the oversized branch now requires
_record_is_structurally_complete(fh, size, line_start) to be True before
accepting _extract_boundary_record_summary's fabricated prefix. An oversized
row that is structurally incomplete (brace depth never returns to 0) is
treated like any other malformed line: skipped, scan continues. This closes
the hole where an oversized malformed predecessor's head could fabricate a
terminal prefix as if the whole row were valid JSON.

## Regressions (4 new, mutation-verified)

- test_tail_read_uses_single_generation_under_delete_recreate: counter on
  Path.open — 1st open returns inode-A (token seq=1 + oversized partial),
  2nd+ returns inode-B (done seq=100 terminal). Asserts the journal is
  opened exactly ONCE and the result is purely inode-A (no seq=100, no
  terminal leak). Mutation A (reopen for boundary summary) FAILS it.
- test_invalid_predecessor_rows_skipped_until_valid_event: reader-level
  coverage of malformed JSON / JSON scalar / JSON list / blank / mixed
  rows before an oversized boundary; both readers recover the token
  (seq=1) + emit apperror. Mutation B (budget=0) FAILS it.
- test_backward_predecessor_scan_budget_bounds_the_scan: a tight budget
  must STOP before reaching a token past 500 malformed rows (None), a
  generous budget reaches it (seq=1). Mutation B (budget=0) FAILS it.
- test_oversized_malformed_predecessor_not_accepted_via_fabricated_prefix:
  an oversized structurally-incomplete predecessor (newline-terminated
  but brace-broken) is skipped via the gate; the valid token (seq=1) is
  recovered, never the fabricated terminal done. Mutation C (gate
  removed) FAILS it.

Existing ~6 test call-sites that called helpers by path updated to the new
(fh, size, ...) signature (open once + os.fstat). The 3 TOCTOU tests
reworked to exercise the single-open TOCTOU (first open raises).

43/43 pass; lint diff-gate clean; git diff --check clean. Round-6
terminator hardening, _read_jsonl (full read), the summary cache, and
routes.py are all unchanged.
rh-id added a commit to rh-id/hermes-webui that referenced this pull request Aug 13, 2026
…#6139 r8)

Round-8 re-gate (2026-07-31T12:10:36Z): one logical tail read still mixed
independently-reopened pathname generations. _read_jsonl_tail opened the
journal once, read the tail, closed the descriptor, then the recovery
helpers each independently reopened the pathname (path.stat + path.open):
_find_record_start_before, _extract_boundary_record_summary,
_record_is_structurally_complete, _read_last_complete_line_before, and the
head-newline counter. A delete-and-recreate between stages could combine
inode-A tail rows/offsets with inode-B boundary/predecessor rows, producing
impossible mixed sequences like [100, 2].

## Fix: thread ONE descriptor + pinned fstat size

_read_jsonl_tail now opens the journal ONCE (fh = path.open('rb')), pins
the size with os.fstat(fh.fileno()).st_size, and passes that same fh + size
through every recovery stage. A try/finally ensures the descriptor is
released on every exit (success/error/early-return). The single top-level
except (FileNotFoundError, OSError) catches a delete during the open; once
open, the handle is stable and all stages read one generation.

All 5 private helpers converted from (path: Path, ...) to (fh, size, ...):
  _find_record_start_before(fh, size, seek_pos)
  _rfind_byte_before(fh, byte, end_offset)
  _record_is_structurally_complete(fh, size, record_start)
  _extract_boundary_record_summary(fh, record_start)
  _read_last_complete_line_before(fh, size, end_offset, *, budget=None)
Each dropped its own path.stat()/path.open() and reads via the passed fh
against the pinned size. No path.stat/path.open remains in any helper.

## Point nesquena#4: explicit aggregate budget for the backward scan

_read_last_complete_line_before gains a budget keyword (default
_SESSION_REPLAY_MAX_BYTES). The loop tracks bytes_consumed and rows_scanned;
when either exceeds the budget / _SESSION_REPLAY_MAX_ROWS, it stops and
returns None. This bounds the scan across an arbitrarily long invalid-row
streak (it can no longer walk to byte 0).

## Point nesquena#5: don't trust oversized-malformed fabricated prefix

In _read_last_complete_line_before, the oversized branch now requires
_record_is_structurally_complete(fh, size, line_start) to be True before
accepting _extract_boundary_record_summary's fabricated prefix. An oversized
row that is structurally incomplete (brace depth never returns to 0) is
treated like any other malformed line: skipped, scan continues. This closes
the hole where an oversized malformed predecessor's head could fabricate a
terminal prefix as if the whole row were valid JSON.

## Regressions (4 new, mutation-verified)

- test_tail_read_uses_single_generation_under_delete_recreate: counter on
  Path.open — 1st open returns inode-A (token seq=1 + oversized partial),
  2nd+ returns inode-B (done seq=100 terminal). Asserts the journal is
  opened exactly ONCE and the result is purely inode-A (no seq=100, no
  terminal leak). Mutation A (reopen for boundary summary) FAILS it.
- test_invalid_predecessor_rows_skipped_until_valid_event: reader-level
  coverage of malformed JSON / JSON scalar / JSON list / blank / mixed
  rows before an oversized boundary; both readers recover the token
  (seq=1) + emit apperror. Mutation B (budget=0) FAILS it.
- test_backward_predecessor_scan_budget_bounds_the_scan: a tight budget
  must STOP before reaching a token past 500 malformed rows (None), a
  generous budget reaches it (seq=1). Mutation B (budget=0) FAILS it.
- test_oversized_malformed_predecessor_not_accepted_via_fabricated_prefix:
  an oversized structurally-incomplete predecessor (newline-terminated
  but brace-broken) is skipped via the gate; the valid token (seq=1) is
  recovered, never the fabricated terminal done. Mutation C (gate
  removed) FAILS it.

Existing ~6 test call-sites that called helpers by path updated to the new
(fh, size, ...) signature (open once + os.fstat). The 3 TOCTOU tests
reworked to exercise the single-open TOCTOU (first open raises).

43/43 pass; lint diff-gate clean; git diff --check clean. Round-6
terminator hardening, _read_jsonl (full read), the summary cache, and
routes.py are all unchanged.
CharlesMcquade added a commit to CharlesMcquade/hermes-webui that referenced this pull request Aug 22, 2026
CORE #1: allow sequential done→stream_end terminal events (no SSE leak)
CORE #2: acquire per-run lock before fence lock in close_acceptance_fence
CORE nesquena#3: resolve(strict=True) archive members, reject symlinks (traversal fix)
SILENT nesquena#4: wire _evict_acceptance_fence into terminal append + journal deletion
SILENT nesquena#5: gate settlement on worklog-worthy predicate + size sanitizer

3 broken regression tests now green. 16 steer fence tests pass.
CharlesMcquade added a commit to CharlesMcquade/hermes-webui that referenced this pull request Aug 22, 2026
CORE #1: allow sequential done→stream_end terminal events (no SSE leak)
CORE #2: acquire per-run lock before fence lock in close_acceptance_fence
CORE nesquena#3: resolve(strict=True) archive members, reject symlinks (traversal fix)
SILENT nesquena#4: wire _evict_acceptance_fence into terminal append + journal deletion
SILENT nesquena#5: gate settlement on worklog-worthy predicate + size sanitizer

3 broken regression tests now green. 16 steer fence tests pass.
CharlesMcquade added a commit to CharlesMcquade/hermes-webui that referenced this pull request Aug 22, 2026
…quena#7188 CORE nesquena#5)

When a turn was auto-compressed mid-run and then cancelled, the run's events
split across two session journals: steer_delivered landed in the ORIGINAL
journal (admission-time session ID), but the cancel terminal was journaled
under the ROTATED continuation ID (cancel_stream resolved _cancel_session_id
from agent.session_id first). find_run_summary() returned only the first
journal glob found, so replay/reattach selected an incomplete journal. The
original acceptance fence stayed closed-but-retained (leaked).

Fix: capture ONE immutable journal session ID at run admission
(active_run_session_id from ACTIVE_RUNS, set before any compression
rotation) and use it for BOTH the fence close AND the cancel terminal
journal in cancel_stream(). The continuation ID is still used for
session/scene persistence (get_session, save, anchor-scene settlement) —
never for run-journal identity.

The completion and error paths were already correct: they use the
RunJournalWriter created at admission (a closure variable that doesn't
change when compression rotates agent.session_id). Only cancel_stream()
created a new RunJournalWriter with the wrong session ID.

Regression test: test_7188_core5_journal_identity_split.py simulates a
mid-run compression (agent.session_id rotated to continuation) then cancels,
and asserts:
- The ORIGINAL journal contains BOTH steer_delivered AND cancel.
- NO continuation journal is created for that run.
- find_run_summary() returns the complete journal.
- The acceptance fence is evicted (not leaked).
- Session/scene persistence still targets the continuation session.
CharlesMcquade added a commit to CharlesMcquade/hermes-webui that referenced this pull request Aug 24, 2026
CORE #1: allow sequential done→stream_end terminal events (no SSE leak)
CORE #2: acquire per-run lock before fence lock in close_acceptance_fence
CORE nesquena#3: resolve(strict=True) archive members, reject symlinks (traversal fix)
SILENT nesquena#4: wire _evict_acceptance_fence into terminal append + journal deletion
SILENT nesquena#5: gate settlement on worklog-worthy predicate + size sanitizer

3 broken regression tests now green. 16 steer fence tests pass.
CharlesMcquade added a commit to CharlesMcquade/hermes-webui that referenced this pull request Aug 24, 2026
…quena#7188 CORE nesquena#5)

When a turn was auto-compressed mid-run and then cancelled, the run's events
split across two session journals: steer_delivered landed in the ORIGINAL
journal (admission-time session ID), but the cancel terminal was journaled
under the ROTATED continuation ID (cancel_stream resolved _cancel_session_id
from agent.session_id first). find_run_summary() returned only the first
journal glob found, so replay/reattach selected an incomplete journal. The
original acceptance fence stayed closed-but-retained (leaked).

Fix: capture ONE immutable journal session ID at run admission
(active_run_session_id from ACTIVE_RUNS, set before any compression
rotation) and use it for BOTH the fence close AND the cancel terminal
journal in cancel_stream(). The continuation ID is still used for
session/scene persistence (get_session, save, anchor-scene settlement) —
never for run-journal identity.

The completion and error paths were already correct: they use the
RunJournalWriter created at admission (a closure variable that doesn't
change when compression rotates agent.session_id). Only cancel_stream()
created a new RunJournalWriter with the wrong session ID.

Regression test: test_7188_core5_journal_identity_split.py simulates a
mid-run compression (agent.session_id rotated to continuation) then cancels,
and asserts:
- The ORIGINAL journal contains BOTH steer_delivered AND cancel.
- NO continuation journal is created for that run.
- find_run_summary() returns the complete journal.
- The acceptance fence is evicted (not leaked).
- Session/scene persistence still targets the continuation session.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants