8 Commits
Author SHA1 Message Date
happy5318 c13a2ecf64 docs(zh): align Windows sections with start.ps1 / WSL-only autostart
Review feedback (nesquena 9/24, PR #7814):

1. §三.1 "首次准备" 原本让用户在 WebUI 仓库本地 `python -m venv venv`
   再 `pip install -r requirements.txt`,但 `start.ps1`(line 132-136)
   启动时优先用 `<hermes-agent>\venv\Scripts\python.exe`,找不到才
   回退到 PATH 上的 `python`——WebUI 本地的 venv 永远不会被它挑中。
   照原指南装的依赖会变成死代码,启动时仍然缺包。重写为:要求把
   requirements.txt 装进 hermes-agent 的 venv,列出 hermes-agent 的
   常见安装位置,保留 WebUI 本地 venv 作备选但明确警告。

2. §三.4 之前是 "Windows 开机自启",但内容(setup_webui_autostart.ps1
   → wsl.exe → scripts/wsl/hermes_webui_autostart.sh)实际上**只**
   启动 WSL 里的 WebUI,放在 native-Windows 章节里会误导用户以为
   native 启动也有自启配套。把它从 §三 抽出,单独成节
   "## 四、WSL 用户:Windows 开机自启",顶部加 WSL-only 提示,
   §四/§五 顺延为 §五/§六,FAQ 里的 cross-ref 同步更新,README
   中文链接文案改为 "...Windows 原生部署(自启仅适用 WSL 用户)"
   不再暗示 native 自启。

3. 顺手处理两条可选小项:

   a. start.sh / start.ps1 / bootstrap.py 都会从仓库 .env 加载
      `HERMES_WEBUI_PASSWORD`,且 .env 里的值**覆盖**命令行内联
      的 env var。在 §三.2 加 callout 提示:改完密码后打开登录页
      确认真的要求输入密码;如未生效就清 .env 或显式
      `HERMES_WEBUI_PRESERVE_ENV=1`。

   b. §六.1 "Tailscale 私有" 原写法 "仅你的 tailnet 设备可达" 在
      方案 B(`HERMES_WEBUI_HOST=0.0.0.0`)下不成立——`0.0.0.0`
      绑的是所有接口,"仅 tailnet" 完全靠防火墙那条
      `100.64.0.0/10` 规则兜底。改为:首选 `HERMES_WEBUI_HOST=
      $(tailscale ip -4)`(只绑 Tailscale 网卡,更严),次选
      `0.0.0.0` + 防火墙,并明确标注"防火墙被改/被关就立刻
      对 LAN/公网开放"。

保留 24dd1fbd 的 public-exposure note 修正。
2026-09-26 01:39:24 +08:00
happy5318 6af1a7e718 fix(models): derive turn-completion from turn-journal for advanced-past-pending predicate (re-gate 2026-09-23)
The suppression predicate _transcript_already_advanced_past_pending()
required a run-journal terminal 'completed' state, but its caller in
_apply_core_sync_or_error_marker already returns early on that same
condition. The predicate branch was therefore unreachable dead code: it
could never suppress anything, and stale-cancel recovery could still
append a duplicate recovered user row, a journal clone and an
interruption marker for a turn that actually finished.

Derive the completion evidence from the crash-safe turn journal instead:
_turn_journal_records_completion() reports True when the exact-stream
'completed' event is the latest terminal state for the pending stream id.
The run journal's terminal write is not durable evidence for this path
(it is consumed by the caller's early return), so the turn journal is the
only still-observable source. A cancelled/crashed turn (interrupted)
keeps recovery active, and completion for another stream of the same
session never settles the pending one.

Also record the same completion event on the Gateway success writeback
path, so Gateway-backed runs have the equivalent completion persistence
instead of leaving the run journal's lost terminal write unrecoverable.

Regression coverage:

- test_turn_journal_helper_reports_completed_for_matching_stream
- test_turn_journal_helper_rejects_interrupted_turn
- test_advances_past_pending_from_turn_journal_without_run_journal_terminal
- test_full_recovery_suppresses_duplicates_on_turn_journal_completion
- test_full_recovery_appends_rows_on_turn_journal_completion_loss
- test_gateway_chat_worker_records_turn_journal_completion

Reverting the predicate gate turns the two new CORE regressions red;
reverting the Gateway write turns the Gateway regression red.
2026-09-24 00:45:41 +08:00
happy5318 5466d09e91 fix(recovery): map vanished-session KeyError to the skip path in WebUI startup recovery (#7753) 2026-09-23 21:23:32 +08:00
happy5318 70646b3502 fix(i18n): explicit server tri-state for language + reviewer 4-row table regression (#7730 round 4)
Round-3 removed the `"language": "en"` schema default so fresh installs stopped
reporting a fake explicit English, and dropped the `primary === 'en'` skip.
The maintainer's round-4 gate asked to complete the tri-state shape:

1. `load_settings()` now emits an explicit `None` for the unset case
   (`settings.setdefault("language", None)`) so `/api/settings` carries a
   real three-way signal instead of omitting the key: `null` = no
   preference, "en" = explicitly saved English, any other code = chosen
   locale.  Stored values (including legacy pre-change English picks) still
   win via the merge and are untouched.
2. New `TestReviewerTableServerTriState` regression loads the real
   `static/i18n.js` in a Node `vm` sandbox and drives
   `resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'),
   _detectBrowserLanguageHint())` — the exact boot.js call shape — for all
   four rows of the maintainer's reproduction table:
   - `en` + new browser (zh-CN) -> en   (was zh under round-2 skip)
   - `en` + stale ja + en-US -> en      (was ja under round-2 skip)
   - `en` + empty + en-US -> en
   - `fr` + empty + zh-CN -> fr

Verification:
- `python3 -c "import api.config"` OK
- 15/15 in test_issue7622_initial_locale_from_browser.py (14 + new table test)
- 8/8 in test_language_precedence.py
- 60/60 settings + 13/13 password-env + login-locale/autosave/sprint26
  collateral tests pass
- ruff_lint --diff origin/master: 0 new violations
2026-09-23 17:03:27 +08:00
happy5318 221f25dcda fix(run-journal): reset folded reason index on timed-flush buffer clear
The ~10 Hz reasoning throttle clears the coalescing buffer before emitting
but left _reasoning_buffer_index holding offsets for text that was just
removed. Subsequent _strip_reasoning_output_echo cut_to() calls then used
raw offsets that no longer correspond to the buffer, so the live-echo strip
truncated the buffer at a phantom position and falsely marked the interim
as reasoning_echo: true — leaking a truncated copy into both the SSE stream
and the durable journal.

Fix: call _reasoning_buffer_index.reset() immediately after zeroing the
buffer in the timed-flush branch, matching the flush/_flush_reasoning_buffer
paths that already keep the folded index in step with the raw text.

Adds tests/test_7569_timed_flush_index_reset.py pinning the call site:
- revert this fix and both tests fail (verified via revert + re-run).
2026-09-21 22:05:12 +08:00
happy5318 5ac3632d58 chore(run-journal): drop now-unused echo helper imports from routes
The journal replay matches through _CompactEchoIndex, so
_find_compact_echo_suffix_start and _strip_compact_echo_suffix are no
longer referenced in api/routes.py. The helpers stay in api/streaming.py
as the equivalence oracle the tests compare against.
2026-09-21 12:34:30 +08:00
happy5318 dcac71c74e fix(run-journal): match echo suffixes through an incremental folded index
Review follow-up for #7569: change 4 of the live-snapshot perf PR replaced
one quadratic with another. The window-free backward walk is correct, but it
re-walks the raw reasoning transcript's whitespace once per interim event —
on a whitespace-heavy transcript that span is megabytes, and the replay went
~55x slower than master on the production shape the gate measured
(1MB / 20-interim journal: 0.035s master → 1.922s branch).

## Thinking Path

The gate's finding was that the cost is structural, not a tweak: the matcher
consults the *whole* accumulated transcript on every interim event, so any
raw-text rescan of it is O(interim_count x whitespace_span). The fix is the
data-structure change the review asked for — maintain the folded view and the
raw cut offsets incrementally, and match/strip from that indexed state
instead of rescanning the raw buffer.

## What Changed

- `api/streaming.py`: new `_CompactEchoIndex` — an append-only folded index
  that records, per non-whitespace character, its raw index. `append` folds a
  chunk once (cost proportional to the chunk, never the buffer),
  `matches_tail`/`cut_to` compare only the candidate against the folded tail
  (O(len(candidate)), no raw walk), `reset` drops the state after the raw
  text is truncated. The old raw helpers (`_find_compact_echo_suffix_start`,
  `_strip_compact_echo_suffix`) stay as the equivalence oracle.
- `api/routes.py::_run_journal_live_snapshot`: reasoning chunks feed the
  index as they are appended; `reasoning_echo_tail_matches` and
  `strip_reasoning_echo_tail` consult the index. The strip re-indexes the
  truncated transcript so later probes match against it.
- `api/streaming.py` live path: the same swap for the live-stream echo strip.
  The three live reasoning buffers (STREAM_REASONING_TEXT, the coalescing
  buffer, the per-message segments) each carry a mirrored index, fed on every
  append and re-indexed after a strip. The buffer flush resets the buffer's
  index with the text it drops, so the index never describes text that is no
  longer there.
- `docs/rfcs/webui-run-state-consistency-contract.md`: the echo-match clause
  now names the indexed representation and the scaling regression that pins
  it.

## Why It Matters

Removing one quadratic by introducing another in the same hot path is the
exact risk the gate flagged — the headline benchmark used a journal shape
where the window-free walk's cost does not dominate, so the aggregate number
still looked like a win. The index keeps the original 8.9x and adds the
scaling guarantee on the shape that was regressing.

## Verification

- Measured on the reproduced shape (1MB whitespace span, 20 interim events):
  0.972s → 0.049s. Doubling both span and interim count scales ~2x per step
  (x1 0.041s / x2 0.090s / x4 0.175s) — linear, not quadratic.
- Behaviour equivalence: 300 randomized journals (whitespace-heavy,
  Unicode, chunked, flagged/unflagged interims) rebuilt with the indexed
  implementation and with the raw walk swapped in — sha256 of the canonical
  snapshot JSON matched on all 300.
- `tests/test_compact_echo_index.py` (new, 6 tests): oracle equivalence over
  2000+ randomized cases, chunked-append equivalence, matches_tail/cut_to
  agreement, reset-after-truncation, and two cost probes that fail the raw
  walk.
- `tests/test_live_snapshot_echo_scan_scaling.py` (new, 4 tests): the
  proportional-scaling regression the review asked for — verified to FAIL on
  the pre-fix matcher (14.6x time for 4x rows, the quadratic signature) and
  pass on the indexed one.
- Existing echo/journal suites unchanged and green: 19 items across
  `test_compact_echo_suffix_linear_scan` / `test_live_snapshot_echo_dedup` /
  `test_live_snapshot_single_parse`, plus 66 neighbouring items
  (`test_issue1361_cancel_data_loss`, `test_issue2565_reasoning_accumulation`,
  `test_issue2713_streaming_segment_flush`, `test_cancel_interrupt`,
  `test_run_journal`).

## Contract Change

None. The matcher's observable behaviour is unchanged (same cut points, same
whitespace-insensitive semantics); only its cost profile changes. No config
knobs, no API surface.

## Risks

- Index drift: if a future writer mutates one of the reasoning buffers
  without feeding (or resetting) its index, the matcher would silently miss
  echoes. Mitigated by keeping the index feed adjacent to every existing
  write site and by the reset-on-flush/reset-on-strip symmetry; the
  equivalence test would catch a dropped feed as a missed echo.
- Memory: the index stores one char plus one int per non-whitespace
  character. On a whitespace-heavy transcript this is bounded by the folded
  length, which is what the transcript would cost folded anyway; the raw
  text is still the larger object.

## Model Used

Hermes Agent (step-5-preview) with direct file edits and pytest verification.
Cost properties were measured with a purpose-built scaling probe (proportional
span x interim count), not a fixed-size fixture; the probe is kept as a
regression test rather than a script.

## Related

#7569 (this PR), #7310 (slow /api/session under load), #7534 (incremental
journal read cache — complementary), #7273/#7291 (metering write-side skip).
2026-09-21 12:34:30 +08:00
happy5318 a44a415876 test(session): drive the bounded-settle splice decision in a Node VM
The round-3 gate reproduced two data-integrity defects against the shipped
functions and noted that the focused adjacent suite stayed green through
both reproductions — the existing coverage for this area asserts source
text, which pins the diff's *text* rather than its *behaviour*. These tests
execute the real decision code instead.

## What Changed

`tests/test_7628_recovery_window_integrity.py` (new, 5 tests): the
bounded-settle splice decision is extracted verbatim from
`static/messages.js` (from the terminal-marker detection through the
`S.messages` assignment) together with its transitive pure dependencies, and
driven through a Node VM with the paging signals as the only variable.

- **Discriminating regression for the splice strategy.** The fixture is a
  visible window of three turns plus a terminal marker against a durable
  tail of the last turn with `_messages_truncated=true, _messages_offset=4`.
  On that shape the correct suffix splice yields
  `[u-3, a-3, marker]`, while the pre-fix `prefix || suffix` expression
  splices at the head offset and re-emits `u-2/a-2/u-3/a-3` — the
  duplication the gate reported. Verified to fail on the pre-fix expression
  and pass on the current one.
- **Paging restore.** `_messagesTruncated` / `_oldestIdx` follow the
  response's own signals for both the truncated and untruncated shapes, so a
  successful `/compress` (bounded preflight, full-transcript response)
  cannot leave the preflight's window state behind.
- **Source lock on the strategy selection.** The strategy must be a
  truncation-gated ternary; a bare `||` between the two comparisons is the
  defect.
- **Source lock on the compress success path.** The paging restore must sit
  between the response assignment and `renderMessages()`, reading
  `data.session._messages_truncated` / `_messages_offset` rather than
  hardcoding `false`.

## Why It Matters

Both findings are silent history corruption: a wrong splice drops or
duplicates transcript rows, and a stale paging offset shifts every absolute
index that edit / fork / regenerate addresses rows by. Neither surfaces as
an error, so the regression has to observe the resulting row list.

## Verification

- 5/5 pass on the current head.
- Reverting only the strategy selection (`prefix || suffix` plus the
  head-offset splice) fails 2 of the 5 — the behavioural splice test and the
  source lock — confirming the tests bite rather than restate the diff.
- `tests/test_session_default_window.py` + `tests/test_7628_recovery_paging.py`
  unchanged and green (30 items together with the new file).
- `scripts/ruff_lint.py --diff origin/master`: no new violations.

## Contract Change

None — tests only.

## Model Used

Hermes Agent (step-5-preview). The discriminating fixture was derived by
executing both the current and the reverted expression through the same
harness and comparing the resulting row lists, not by reading the diff.

## Related

#7628 (this PR), #7310 (slow /api/session under load), #7625 (bounded
recovery measurement).
2026-09-20 21:44:01 +08:00