Skip to content

Fix update reload readiness race - #3654

Closed
franksong2702 wants to merge 2 commits into
nesquena:masterfrom
franksong2702:franksong2702/fix-3619-update-reload-new-process
Closed

Fix update reload readiness race#3654
franksong2702 wants to merge 2 commits into
nesquena:masterfrom
franksong2702:franksong2702/fix-3619-update-reload-new-process

Conversation

@franksong2702

Copy link
Copy Markdown
Contributor

Thinking Path

  • Hermes WebUI self-update should restart the local server and reload only after the replacement process is ready.
  • The existing helper already polls /health, but it accepted any healthy response.
  • During the scheduled restart window, the old process can still answer /health with status: "ok".
  • This PR captures the pre-update /health.uptime_seconds baseline and only reloads after a later healthy probe proves a new process by reporting a lower uptime.
  • The result is a small readiness-contract hardening instead of a blind longer timeout.

What Changed

  • Added a _readHealthUptimeSeconds() helper that reads the mount-relative /health endpoint.
  • Updated applyUpdates() and forceUpdate() to pass a baseline uptime into _waitForServerThenReload().
  • Updated _waitForServerThenReload() to keep polling while healthy probes still look like the old process.
  • Added focused regression coverage, including a Node-driven behavioral test that verifies old-process health is ignored until a new-process uptime appears.
  • Added a release-note-ready CHANGELOG.md entry.

Why It Matters

  • Prevents the browser from reloading into an update/restart race where the old process answered /health just before exiting.
  • Keeps the existing subpath-safe health URL behavior.
  • Avoids masking the bug with a longer fixed sleep.

Verification

  • python -m pytest tests/test_update_banner_fixes.py -k "wait_for_server_polls_health or wait_for_server_requires_new_uptime or wait_for_server_ignores_old_process_health_until_new_uptime or apply_and_force_updates_capture_uptime" -q
  • python -m pytest tests/test_update_banner_fixes.py tests/test_update_apply_ui.py -q
  • node --check static/ui.js
  • git diff --check

Risks / Follow-ups

  • If the baseline uptime probe is unavailable, the helper preserves the existing readiness behavior rather than blocking reload forever.
  • Extremely fresh old-process baselines may require polling until timeout if the new process uptime is not lower yet, but this is preferable to reloading into the old process.

Model Used

  • AI-assisted.
  • Coordinator: OpenAI GPT-5 Codex.
  • Worker sub-agent: OpenAI gpt-5.3-codex-spark (Hegel).
  • Coordinator added the final behavior-level acceptance test and ran verification.

Fixes #3619

@greptile-apps

greptile-apps Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the update-reload readiness guard from an uptime-seconds comparison to a stable process-identity comparison using server_started_at, a new field added to the /health response. The approach is sound: the backend exposes a constant epoch timestamp per process, and the client polls until the identity changes before triggering a reload.

  • api/routes.py: Adds server_started_at: SERVER_START_TIME to the health payload so the client can distinguish old-process from new-process responses.
  • static/ui.js: Replaces _readHealthUptimeSeconds / uptime_seconds logic with _readHealthServerIdentity / server_started_at; both applyUpdates and forceUpdate now correctly capture the baseline before triggering the restart API call.
  • Tests: New behavioral Node-driven tests exercise the wait loop end-to-end; ordering assertions tighten the capture-before-trigger contract for both update paths.

Confidence Score: 5/5

Safe to merge; the core logic is correct and the previously reported baseline-ordering bug in forceUpdate is fixed.

Both update paths correctly capture the process identity before triggering the restart API call. The new server_started_at field is a stable constant per process, making it a reliable discriminator. The fallback behavior (null baseline → reload on first healthy probe) is preserved and exercised. The only gaps are in test coverage: behavioral tests simulate string-typed identity values while the server emits a numeric float, and the applyUpdates ordering assertion does not pin against the API call site — neither affects the runtime behavior shipped in this PR.

No files require special attention for merging; the test gaps in test_update_banner_fixes.py are worth addressing in a follow-up.

Important Files Changed

Filename Overview
static/ui.js Switches readiness guard from uptime-seconds to server_started_at identity; baseline capture ordering is correct in both applyUpdates and forceUpdate; null-identity fallback behavior unchanged from previous threads.
api/routes.py Adds server_started_at (a constant float) to the health payload; straightforward one-liner addition with no logic changes.
tests/test_update_banner_fixes.py Adds behavioral Node tests and ordering assertions; behavioral tests use string-typed server_started_at values while the real server emits a JSON float, leaving the numeric normalization branch untested end-to-end.
tests/test_update_apply_ui.py Minor update to broaden the _waitForServerThenReload string search to match the new call-with-options form; no logic issues.

Sequence Diagram

sequenceDiagram
    participant Browser
    participant OldServer as Old Server Process
    participant NewServer as New Server Process

    Browser->>OldServer: GET /health (baseline capture)
    OldServer-->>Browser: "{status:'ok', server_started_at: T0}"
    note over Browser: baselineServerIdentity = normalize(T0)

    Browser->>OldServer: POST /api/updates/apply (or /force)
    OldServer-->>Browser: 200 OK (restart triggered)

    loop Poll until identity changes or timeout (15s)
        Browser->>OldServer: GET /health (old process still alive)
        OldServer-->>Browser: "{status:'ok', server_started_at: T0}"
        note over Browser: identity == baseline → keep polling
        Browser->>NewServer: GET /health
        NewServer-->>Browser: "{status:'ok', server_started_at: T1}"
        note over Browser: T1 ≠ T0 → reload!
    end

    Browser->>Browser: location.reload()
Loading

Reviews (2): Last reviewed commit: "Remove stray changelog whitespace" | Re-trigger Greptile

Comment thread static/ui.js Outdated
Comment on lines +5714 to +5723
if (baselineUptimeSeconds===null){
location.reload();
return;
}
const nextUptimeSeconds=Number(data&&data.uptime_seconds);
if(Number.isFinite(nextUptimeSeconds) && nextUptimeSeconds < baselineUptimeSeconds){
location.reload();
return;
}
// Keep polling while the server keeps reporting the same (pre-restart) process

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 If the baseline was captured successfully (non-null) but the restarted server's health probe returns status: 'ok' without a uptime_seconds field, Number(undefined) yields NaN, Number.isFinite(NaN) is false, and the condition never fires. The browser will poll silently for the full 15 s then show "click Reload when ready" even though the server is already healthy. Any environment where the health schema omits uptime_seconds (even conditionally) will regress to a manual-reload UX after applying updates.

Comment thread tests/test_update_banner_fixes.py Outdated
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Read the full diff (static/ui.js:5608-5610, 5680-5682, 5694-5728), the /health handler it depends on, and all three new tests. This is the right fix for #3619 — it implements exactly the uptime-baseline approach (Option 1) that came up in the issue thread, and it's the correct call over a longer blind delay.

The /health contract this depends on is real

_handle_health returns the field the client now reads, derived from a process-global start time:

# api/routes.py:4435
"uptime_seconds": round(time.time() - SERVER_START_TIME, 1),

and SERVER_START_TIME = time.time() is set once at import in api/config.py:4912, so it resets on every os.execv re-exec. The "new process reports a lower uptime" invariant the PR relies on holds. The status==='ok' gate in the poll also matches — _handle_health only emits ok when the stream-lock check passes, otherwise degraded/503. Good.

The behavioral test actually exercises the logic

test_wait_for_server_ignores_old_process_health_until_new_uptime extracts the real _waitForServerThenReload body, drives it under Node with a fake fetch returning uptime_seconds: 120 (old) then 2 (new) against baselineUptimeSeconds: 100, and asserts the old-process ok is ignored and exactly one reload fires after the lower uptime appears. That's a real regression guard, not just a string match.

Minor: baseline capture ordering is inconsistent between the two callers

applyUpdates() captures the baseline before the apply POST loop (ui.js:5609):

const baselineUptimeSeconds = await _readHealthUptimeSeconds();
for(const target of targets){ ... }

but forceUpdate() captures it after the force POST returns (ui.js:5681):

const res=await api('/api/updates/force',{...});
...
const baselineUptimeSeconds = await _readHealthUptimeSeconds();

In practice this is safe because the server delays the re-exec — _schedule_restart(delay=2.0) (api/updates.py:1015), gated further by _wait_until_restart_safe — so the post-POST read still hits the old process. But it's racier than the applyUpdates path for no benefit. Recommend moving the _readHealthUptimeSeconds() call above the force POST in forceUpdate for symmetry, so both paths capture the baseline while the old process is unambiguously still the one answering.

Residual edge the author already flagged (uptime-only has a fresh-baseline corner)

The PR notes "extremely fresh old-process baselines may require polling until timeout." Concretely: if the server was started seconds ago (baseline ≈ 1.5s) when the user clicks update, the new process can accumulate >1.5s of uptime before the client's first successful probe, so nextUptime < baseline stays false and the poll rides out to the 15s maxMs and shows "taking longer than expected." Falls back gracefully (manual reload works), but it's a real corner.

If you want to close it definitively rather than relying on uptime monotonicity, add a stable boot_id (or PID) to the /health payload next to uptime_seconds, capture it as the baseline, and reload as soon as the probed boot_id differs. That removes the dependence on the new process having a smaller clock value and is robust regardless of how fresh the old process was. Not a blocker for this PR — the uptime approach handles the normal case (post-restart uptime_seconds near zero vs. a baseline of minutes/hours) cleanly — but worth a follow-up note.

Otherwise this is clean and well-tested. LGTM with the forceUpdate ordering tweak.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Re-reviewed in a fresh release gate. Thanks @franksong2702 — the goal (don't reload until the replacement server is actually up) is exactly right, and the /health.uptime_seconds baseline idea is a sound approach. But the regression gate (Codex, verified by running the code) surfaced a chain of issues, and the last one is a design-level concern that needs a backend /health change, so keeping hold + changes-requested for a rework.

I shipped the PDF-preview half of the original batch separately (v0.51.278); this PR is held on its own.

The issues found (in order of depth)

1. null baseline was defeated by numeric coercion. _readHealthUptimeSeconds() returns null when pre-update /health is unreadable, but _waitForServerThenReload() did Number.isFinite(Number(opts.baselineUptimeSeconds)) ? Number(...) : null — and Number(null) === 0 (finite), so the baseline became 0, making the intended baselineUptimeSeconds===null fallback-reload branch unreachable. Since no real uptime is < 0, the poll times out and the page never auto-reloads. (Fix: preserve null/undefined before the numeric coercion.)

2. forceUpdate() read the baseline on the wrong side of the POST. It captured baselineUptimeSeconds after await api('/api/updates/force', …) returned — but the backend schedules the restart while handling that request, so the follow-up /health can hit the new process, making the baseline reflect the new proc → the < gate never trips → hang. (Fix: capture the baseline before the POST, the way applyUpdates() does.)

3. The core design flaw — raw-uptime comparison is not a reliable "new process" signal. Even with 1 and 2 fixed, the gate nextUptimeSeconds < baselineUptimeSeconds fails when the old process was very fresh. Verified by running _waitForServerThenReload({baselineUptimeSeconds: 0.2}) against a healthy uptime_seconds: 1.0 response: the new process legitimately answers at 1.0s, 1.0 < 0.2 is false, so it never reloads — both applyUpdates() and forceUpdate() sit on the restart banner until the 15s timeout / manual reload. /health only exposes a rounded uptime_seconds (api/routes.py ~4340), so uptime alone can't distinguish "old proc, low uptime" from "new proc, low uptime."

Suggested rework — compare process identity, not raw uptime

Add a stable per-process boot identity to /health and gate the reload on that changing:

  • Backend: expose a server_started_at (epoch, set once at process start) or a random boot_id on /health (api/routes.py).
  • Frontend: capture that identity before the apply/force POST (both paths), and reload when a later healthy probe reports a different identity. Keep the existing null-baseline fallback (reload on first healthy response) for when the pre-update probe failed.

That makes the readiness signal correct regardless of how fresh either process is. Items 1 and 2 fold into this naturally once the comparison is identity-based. Happy to review again as soon as the boot-id approach is in — the rest of the structure (_waitForServerThenReload, the banner, the poll loop) is good and can stay.

@nesquena-hermes nesquena-hermes added hold changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address labels Jun 5, 2026
@franksong2702
franksong2702 force-pushed the franksong2702/fix-3619-update-reload-new-process branch from 3d12ab5 to 2a2d307 Compare June 6, 2026 00:11
@franksong2702

Copy link
Copy Markdown
Contributor Author

Coordinator update:

  • Reworked the restart-readiness gate to compare /health.server_started_at process identity instead of raw uptime.
  • Captures the baseline identity before both applyUpdates() and forceUpdate() POSTs.
  • Preserves null/failed-baseline fallback: reload on the first healthy response.
  • Cleaned the branch onto latest origin/master (v0.51.283 / a7241f7); no changelog-only noise remains.
  • Local verification: /Users/xuefusong/.hermes/hermes-agent/venv/bin/python -m pytest tests/test_update_banner_fixes.py tests/test_update_apply_ui.py -q => 82 passed.
  • JS syntax: node --check static/ui.js.
  • Whitespace: git diff --check origin/master..HEAD.
  • Coordinator behavioral acceptance also executed the extracted UI functions to verify old-process same-identity health is ignored until a changed identity appears, and apply/force baseline reads happen before their POSTs.

eleboucher pushed a commit to eleboucher/homelab that referenced this pull request Jun 6, 2026
…➔ 0.51.293) (#856)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/nesquena/hermes-webui](https://github.com/nesquena/hermes-webui) | patch | `0.51.277` → `0.51.293` |

---

### Release Notes

<details>
<summary>nesquena/hermes-webui (ghcr.io/nesquena/hermes-webui)</summary>

### [`v0.51.293`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051293--2026-06-06--Release-JI-stage-s5--thinking-card-no-longer-renders-twice)

[Compare Source](nesquena/hermes-webui@v0.51.292...v0.51.293)

##### Fixed

- **The "Thinking" card no longer renders twice on a settled turn.** For a turn that had both a tool call and reasoning (e.g. think → call a tool → answer), the thinking card could appear once inside the collapsed **Activity** group at the top of the turn and again as a stranded second card below the answer and the `Done in …` footer. The thinking-only inline render path (added in v0.51.258 for [#&#8203;3592](nesquena/hermes-webui#3592)) now only fires when the turn has no Activity group of its own, and when it does render inline it inserts the card **above** the answer body instead of after the footer. Thinking that echoes the visible answer on a trailing reasoning-only message is also de-duplicated against the whole turn's answer text now, not just the same message's body. Genuinely thinking-only turns still show their thinking inline (the [#&#8203;3592](nesquena/hermes-webui#3592) fix is preserved, not reverted). ([#&#8203;3709](nesquena/hermes-webui#3709); supersedes [#&#8203;3708](nesquena/hermes-webui#3708))

### [`v0.51.292`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051292--2026-06-06--Release-JH-stage-s4--compression-exhausted-turns-surface-as-errors-not-fake-completions)

[Compare Source](nesquena/hermes-webui@v0.51.291...v0.51.292)

##### Fixed

- **Context-compression-exhausted turns are no longer finalized as a falsely "completed" response.** When Hermes Agent exhausts context compression in a long tool-heavy turn, the streamed result can end on a tool result or an assistant `tool_calls` turn with no final assistant answer. WebUI previously rendered that as a settled, completed reply. It now classifies a persisted transcript that ends in a tool/tool-call/empty-assistant tail (or an internal `[CONTEXT COMPACTION — REFERENCE ONLY]` marker) — and `compression_exhausted`/`failed`/`partial` agent results — as a terminal failure and surfaces a clear error instead. The compression session-id migration and pre-compression snapshot now run **before** the terminal-failure path returns, so frontend/backend session state stays consistent when exhaustion fires after the agent rotates `session_id`. ([#&#8203;3316](nesquena/hermes-webui#3316), [@&#8203;franksong2702](https://github.com/franksong2702); fixes [#&#8203;3315](nesquena/hermes-webui#3315))

### [`v0.51.291`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051291--2026-06-06--Release-JG-stage-s2--preserve-live-turn-content-when-switching-away-mid-stream)

[Compare Source](nesquena/hermes-webui@v0.51.290...v0.51.291)

##### Fixed

- **Switching away from a streaming session no longer loses the in-progress thinking/tool content.** When you clicked to another chat while a session was streaming during a quiet window (mid tool-execution or silent reasoning, between content events) and then switched back, the live turn's tool cards and thinking could disappear permanently — only the elapsed-time clock survived — until the response finished and the transcript re-rendered from the server. Cause: the live-turn DOM snapshot was only captured on content/`tool_complete` SSE events, so the switch-away teardown could run with a stale-or-absent snapshot, and the switch-back fallback rebuilt an empty thinking card. `closeLiveStream()` now snapshots the live turn **before** tearing the stream down, so switching back restores the exact state shown at switch-away. ([#&#8203;3668](nesquena/hermes-webui#3668))

### [`v0.51.290`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051290--2026-06-06--Release-JF-stage-s1--profile-providermodel-now-respected-in-session-resolution)

[Compare Source](nesquena/hermes-webui@v0.51.289...v0.51.290)

##### Fixed

- **Profile-bound sessions now resolve their provider and model from the profile** instead of silently falling back to the global active provider. Previously, when a chat was started under a profile and the model string was not `@provider:`-qualified (and no explicit provider was sent), the backend used the catalog's global active provider — so a profile wired to one provider/key could silently run on a different one, causing **wrong credentials/billing** and **silent context truncation** (the global default model's advertised context window could differ from what the provider actually served, so the provider dropped the oldest messages and long chats "forgot" earlier content). Resolution is now authoritative from the profile across all four runtime entry points (chat start, streaming worker incl. background/btw runs, and both deferred `/api/session` display resolvers); stale models are still repaired under the profile provider — including the `openai-codex` profile + stale `openai/…` slash-model case — while native slash IDs on OpenRouter/custom providers are preserved and explicit `@provider:` qualifiers still win. ([#&#8203;3448](nesquena/hermes-webui#3448), [@&#8203;rodboev](https://github.com/rodboev); fixes [#&#8203;3405](nesquena/hermes-webui#3405))

### [`v0.51.289`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051289--2026-06-06--Release-JE-hotfix--sidebar-ReferenceError-3696--scope-undef-prevention-gate)

[Compare Source](nesquena/hermes-webui@v0.51.288...v0.51.289)

##### Fixed

- **Sidebar no longer crashes with `ReferenceError: _sessionAttentionState is not defined`.** The session-attention helper was declared *inside* `renderSessionListFromCache()` and relied on function hoisting, but the top-level `_sidebarRowHasVisibleMessages` (reached via `renderSessionListFromCache` → `_partitionSidebarSessionRows`) called it bare — and hoisting is scoped to the enclosing function, so every sidebar cache-render threw and the session list went blank. `_sessionAttentionState` is now a top-level function reachable by both call sites. Regressed in [#&#8203;3672](nesquena/hermes-webui#3672) (v0.51.269). ([#&#8203;3696](nesquena/hermes-webui#3696))
- **Stale-stream terminal events no longer risk a `ReferenceError: source is not defined`.** `_bailOutOfTerminalEventsFromStaleStream` (declared inside `attachLiveStream`) called `_closeSource(source)` against a `source` that was not in its lexical scope — it would have thrown on the late-finalizing-stream path when the user is back in an active session. `source` is now threaded as an explicit parameter. Found by the new scope gate below during review. ([#&#8203;3696](nesquena/hermes-webui#3696))

##### Internal

- **New static-JS scope/undefined-reference gate (`scripts/scope_undef_gate.py`).** Models the WebUI's classic-`<script>` shared global scope and runs ESLint `no-undef` per file, flagging a function that is defined only *nested* but called from a sibling/top-level scope — the brick class behind [#&#8203;3696](nesquena/hermes-webui#3696) that `node --check`, source-presence tests, and the existing `no-const-assign` runtime gate all miss. Wired into the CI `lint` job alongside the `no-const-assign`/`no-import-assign` runtime gate, with an in-suite test (`tests/test_static_js_scope_undef.py`) and a focused structural regression test (`tests/test_issue3696_session_attention_scope.py`). ([#&#8203;3696](nesquena/hermes-webui#3696))

### [`v0.51.288`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051288--2026-06-06--Release-JD-stage-r24--collapsible-approval-card)

[Compare Source](nesquena/hermes-webui@v0.51.287...v0.51.288)

##### Added

- **The tool-call approval card can be collapsed to a thin header strip.** A chevron toggle in the approval-card header shrinks the card to just its "Approval required" heading so the tool-call rationale and transcript scrolled above it stay readable; clicking again re-expands it. Includes full ARIA (`aria-expanded`/`aria-controls`/`aria-label`), an icon swap, and transcript reflow that preserves a near-bottom scroll position. State resets to expanded for each new approval, so a fresh approval is never hidden. ([#&#8203;3515](nesquena/hermes-webui#3515), [@&#8203;rodboev](https://github.com/rodboev); closes [#&#8203;3007](nesquena/hermes-webui#3007))

### [`v0.51.287`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051287--2026-06-06--Release-JC-stage-r22--WeCom-session-classification--worker-profile-picker-hiding)

[Compare Source](nesquena/hermes-webui@v0.51.286...v0.51.287)

##### Fixed

- **WeCom gateway sessions are now classified as messaging conversations.** Rows arriving with raw sources `wecom` / `wecom_callback` are normalized into the messaging category (alongside weixin/telegram/discord/slack/email) and given proper "WeCom" / "WeCom Callback" display names, so they group and surface correctly in the sidebar. ([#&#8203;3653](nesquena/hermes-webui#3653), [@&#8203;franksong2702](https://github.com/franksong2702))

##### Changed

- **Worker profiles are hidden from the chat profile picker.** Worker profiles (used for orchestrator/Kanban dispatch) are no longer offered as normal human chat targets in the picker, while still appearing in the profile management view with a "Hidden from chat" badge. The active profile is never hidden. ([#&#8203;3662](nesquena/hermes-webui#3662), [@&#8203;Chukwuebuka-20](https://github.com/Chukwuebuka-20))

### [`v0.51.286`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051286--2026-06-06--Release-JB-stage-r21--sidebar-tab-reordering)

[Compare Source](nesquena/hermes-webui@v0.51.285...v0.51.286)

##### Added

- **Drag-reorder for sidebar tabs.** In Settings → Appearance, the "Sidebar tabs" chips (Tasks, Kanban, Skills, Memory, Spaces, Profiles, Todos, Insights, Logs) can be dragged to reorder how they appear in the left rail and sidebar nav, persisted via a sanitized `tab_order` setting (collapses duplicates, rejects `chat`/`settings`, strips non-strings). Chat and Settings stay fixed. Reorder is pointer/desktop-based (consistent with the existing Kanban drag-and-drop); the chips remain tappable for show/hide on touch. ([#&#8203;3067](nesquena/hermes-webui#3067), [@&#8203;ai-ag2026](https://github.com/ai-ag2026))

### [`v0.51.285`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051285--2026-06-06--Release-JA-stage-r19--update-reload-server-identity-race-fix)

[Compare Source](nesquena/hermes-webui@v0.51.284...v0.51.285)

##### Fixed

- **Don't reload the page until the *replacement* server is actually up after an update.** The post-update reload previously compared raw `/health` uptime, which couldn't distinguish a still-running old process from the restarted one (it could reload against the old process or hang). The client now reads a stable `server_started_at` identity before the update POST and reloads only once `/health` reports a *different* identity (with a null-baseline fallback). Both the force-update and regular apply paths read and pass the baseline. ([#&#8203;3654](nesquena/hermes-webui#3654), [@&#8203;franksong2702](https://github.com/franksong2702); [#&#8203;874](nesquena/hermes-webui#874))

### [`v0.51.284`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051284--2026-06-05--Release-IZ-stage-w4--sidebar-status-labels--cron-sessions-toggle)

[Compare Source](nesquena/hermes-webui@v0.51.283...v0.51.284)

##### Added

- **Manual session status labels (Todo / In Progress / Done).** Tag any session from its row's ⋯ menu with a colored status badge (blue Todo / amber In Progress / green Done), stored per-session in localStorage. The badge renders inline on the sidebar row and uses theme variables so it adapts to light/dark and skins. ([#&#8203;3570](nesquena/hermes-webui#3570), [@&#8203;rodboev](https://github.com/rodboev))
- **"Show cron sessions" preference** (Settings → Preferences). Surfaces cron-job output as conversations in the sidebar. Off by default and gated under "Show non-WebUI sessions" — only active once non-WebUI sessions are enabled — with a note that high-frequency jobs can flood the sidebar. ([#&#8203;3514](nesquena/hermes-webui#3514), [@&#8203;rodboev](https://github.com/rodboev); closes [#&#8203;2841](nesquena/hermes-webui#2841))

### [`v0.51.283`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051283--2026-06-05--Release-IY-stage-w2--composer-queue-hint-during-auto-compaction)

[Compare Source](nesquena/hermes-webui@v0.51.282...v0.51.283)

##### Fixed

- **The composer now tells you a message will queue during auto-compaction instead of looking dead.** While automatic compression runs, the send button previously went `disabled` with only a "Waiting for compression to finish" tooltip. It now shows a `queue` action with the placeholder + tooltip "Type a message — it will queue and send after compression", so you can type and have it sent automatically when compaction completes. ([#&#8203;3512](nesquena/hermes-webui#3512), [@&#8203;rodboev](https://github.com/rodboev); closes [#&#8203;3079](nesquena/hermes-webui#3079))

### [`v0.51.282`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051282--2026-06-05--Release-IX-stage-3544--surface-memoryskill-saves-in-Activity-summary)

[Compare Source](nesquena/hermes-webui@v0.51.281...v0.51.282)

##### Added

- **The collapsed Activity summary now shows when the agent saved a memory or updated a skill** — e.g. "Activity: 2 tools, 1 memory saved, 1 skill updated" — so persistent-state changes are visible at a glance without expanding the group. Detection matches the real tool action vocabularies (`memory`: add/replace count as saves, `remove` excluded; `skill_manage`: create/patch/edit/write\_file count as updates, delete/remove\_file excluded), and only completed, non-errored calls are counted. The memory/skill counts are subtracted from the tool count so it reflects only non-memory/skill tools. Classification is stamped as durable `data-*` attributes so the suffix survives the live tool-call group's HTML snapshot/restore on session switch. Sessions with no memory/skill writes render the unchanged "Activity: N tools" label. ([#&#8203;3544](nesquena/hermes-webui#3544), [@&#8203;rodboev](https://github.com/rodboev); closes [#&#8203;3340](nesquena/hermes-webui#3340))

### [`v0.51.281`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051281--2026-06-05--Release-IW-stage-verdigris--Verdigris-emeraldbronze-skin)

[Compare Source](nesquena/hermes-webui@v0.51.280...v0.51.281)

##### Added

- **New "Verdigris" appearance skin** — a dark-only emerald/forest-green palette (`#&#8203;0F1714` background, `#&#8203;121D18` sidebar) with bronze-gold accents (`#C89A5A`), named for the green-bronze patina on aged copper. Selectable in Settings → Appearance and via `/theme verdigris`. Fully scoped under `:root.dark[data-skin="verdigris"]` (no bleed into the default appearance or other skins), with component-level accents for the new-chat button, scrollbar, tool cards, tree viewer, session badges/tags, diff blocks, MCP status, and image lightbox. ([#&#8203;3602](nesquena/hermes-webui#3602), [@&#8203;rodboev](https://github.com/rodboev); closes [#&#8203;3357](nesquena/hermes-webui#3357))

### [`v0.51.280`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051280--2026-06-05--Release-IV-stage-p3i--Windows-self-update-restart-fix)

[Compare Source](nesquena/hermes-webui@v0.51.279...v0.51.280)

##### Fixed

- **Self-update now restarts correctly on Windows.** `os.execv` does not replace the current process on Windows (it spawns a new one while the old keeps running), so the old process held port 8787 and the new process failed to bind ("address already in use"), surfacing as "Update failed" after the timeout. On Windows the restart now launches a detached new process (`subprocess.Popen` with `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`) and exits the old one immediately to release the port, plus a bounded bind-retry loop in `server_bind()` (up to 10s) to ride out the `SO_EXCLUSIVEADDRUSE` teardown window. POSIX behavior is unchanged (still `os.execv`). ([#&#8203;3647](nesquena/hermes-webui#3647), [@&#8203;jja881](https://github.com/jja881))

### [`v0.51.279`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051279--2026-06-05--Release-IU-stage-p3h--preserve-Activitystreaming-turn-on-mid-stream-scroll)

[Compare Source](nesquena/hermes-webui@v0.51.278...v0.51.279)

##### Fixed

- **Loading earlier messages during an active stream no longer wipes the Activity panel or the current streaming turn.** Two causes: (1) the message merge/dedup keys didn't include `tool_calls`, so assistant messages invoking *different* tools with identical empty content and same-second timestamps collapsed into one — dropping every state.db tool-call after the first the sidecar registered; (2) `_syncToolCallsForLoadedMessages` cleared `S.toolCalls` while `S.busy` blocked the `renderMessages` rebuild. `tool_calls` is now part of the merge/dedup/visible keys (with a preservation branch so distinct tool invocations within the sidecar timestamp window aren't skipped), and the frontend keeps the live tool-call/streaming state when paging in history. ([#&#8203;3665](nesquena/hermes-webui#3665), [@&#8203;mysoul12138](https://github.com/mysoul12138); fixes [#&#8203;3346](nesquena/hermes-webui#3346))

### [`v0.51.278`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051278--2026-06-05--Release-IT-stage-p3g--repair-inline-PDF-preview)

[Compare Source](nesquena/hermes-webui@v0.51.277...v0.51.278)

##### Fixed

- **Inline PDF preview in chat now renders again.** The PDF.js loader previously created a `<script>` with both `src` and `textContent` set (the latter is ignored when `src` is present), so PDF.js never initialized and the preview hung on the spinner before degrading to a download link. It now loads PDF.js via a blob module script that sets the worker source, passes `isEvalSupported:false` to harden the parser, and revokes the blob URL on load. CSP gains `blob:` in `script-src` and a scoped `worker-src blob: 'self' https://cdn.jsdelivr.net` to permit the worker. ([#&#8203;3652](nesquena/hermes-webui#3652), [@&#8203;xx77yy](https://github.com/xx77yy); closes [#&#8203;3649](nesquena/hermes-webui#3649))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMDEuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEwMS4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19-->

Reviewed-on: https://git.erwanleboucher.dev/eleboucher/homelab/pulls/856
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
…tity race fix nesquena#3654) (nesquena#3693)

* Fix update reload readiness race — poll /health server identity before reload (nesquena#3654)

Replaces the raw-uptime comparison (couldn't distinguish a fresh old process
from the restarted one) with a stable server_started_at identity read before
the update POST; reloads only when the identity changes. Both the force-update
and regular apply paths read + pass the baseline. (nesquena#874, nesquena#3654)

Co-authored-by: Frank Song <franksong2702@gmail.com>

* docs(changelog): v0.51.285 — Release JA (stage-r19)

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address hold

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Update refresh timeout too short

2 participants