Release: terminal-error settlement timing/seal (#6323) + Docker experimental builds (#6329) (#6341)

* fix: preserve timing and seal tool rows on terminal error (#6309)

* fix: publish Docker experimental builds to ghcr (#6298)

- Add exp-v* trigger to release workflow so experimental tags build
  and push Docker images
- Add :experimental floating tag for experimental channel, keeping
  :latest scoped to stable v* tags only
- Mark GitHub Releases from exp-v* tags as pre-releases
- Document available Docker tags (:latest, :experimental, version
  pins) in docs/docker.md

Closes #6298

* docs(changelog): stamp #6323 terminal-error timing/seal + #6329 Docker exp builds

---------

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
nesquena-hermes 2026-07-19 15:17:36 -07:00 committed by GitHub
parent 10e5c52b10
commit 307b7fce5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 78 additions and 2 deletions

View file

@ -4,6 +4,7 @@ on:
push: push:
tags: tags:
- 'v*' - 'v*'
- 'exp-v*'
jobs: jobs:
release: release:
@ -15,11 +16,22 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Check whether this is a stable or experimental release
- name: Detect release channel
id: channel
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "is_stable=true" >> "$GITHUB_OUTPUT"
else
echo "is_stable=false" >> "$GITHUB_OUTPUT"
fi
# Create GitHub Release from tag with auto-generated notes # Create GitHub Release from tag with auto-generated notes
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
generate_release_notes: true generate_release_notes: true
prerelease: ${{ steps.channel.outputs.is_stable == 'false' }}
# Set up multi-arch build (QEMU + Buildx) # Set up multi-arch build (QEMU + Buildx)
- uses: docker/setup-qemu-action@v3 - uses: docker/setup-qemu-action@v3
@ -41,7 +53,9 @@ jobs:
images: ghcr.io/${{ github.repository }} images: ghcr.io/${{ github.repository }}
tags: | tags: |
type=match,pattern=v(\d+\.\d+(?:\.\d+)?),group=1 type=match,pattern=v(\d+\.\d+(?:\.\d+)?),group=1
type=raw,value=latest type=match,pattern=exp-v(\d+\.\d+(?:\.\d+)?),group=1
type=raw,value=latest,enable=${{ steps.channel.outputs.is_stable == 'true' }}
type=raw,value=experimental,enable=${{ steps.channel.outputs.is_stable == 'false' }}
# Build and push multi-arch image (amd64 + arm64) # Build and push multi-arch image (amd64 + arm64)
- name: Build and push Docker image - name: Build and push Docker image

View file

@ -17,6 +17,10 @@
- **Watching the same terminal from two tabs no longer splits the output stream.** Terminal output is now broadcast to every subscribed viewer instead of draining from one shared queue, so a second tab (or a reconnect) sees the full stream rather than a random half. SSE responses carry event `id:`s and a reconnecting viewer replays only events after its `Last-Event-ID` cursor (fresh viewers still get the full bounded backlog). Thanks @ai-ag2026. (#5836) - **Watching the same terminal from two tabs no longer splits the output stream.** Terminal output is now broadcast to every subscribed viewer instead of draining from one shared queue, so a second tab (or a reconnect) sees the full stream rather than a random half. SSE responses carry event `id:`s and a reconnecting viewer replays only events after its `Last-Event-ID` cursor (fresh viewers still get the full bounded backlog). Thanks @ai-ag2026. (#5836)
- **A turn that ends in an error no longer shows impossible "still running" tool work or a wrong duration.** On a terminal error, the settled (and reloaded) Worklog now freezes the turn's duration before cleanup and seals any still-projected tool rows as done, so a settled/reloaded transcript can't disagree with the terminal state. Successful and cancel paths are unchanged. Thanks @webtecnica. (#6323, #6309)
- **Docker: experimental builds are now published to ghcr.** The image release workflow previously fired only on stable `v*` tags, so Docker/Portainer users couldn't track the experimental channel at all. It now also builds on `exp-v*` tags, publishing a floating `:experimental` tag (and marking the GitHub release as a prerelease) while the stable `:latest` / `v*` path is unchanged. Thanks @webtecnica. (#6329, #6298)
- **A gateway command-approval card with no `approval_id` is now respondable.** When a gateway `approval.request` omits both `approval_id` and the legacy `id`, WebUI now synthesizes a non-empty `uuid4().hex` id instead of leaving it blank, so the approval card can be answered (matching the `#527` stable-uuid design). The downstream respond path resolves by `run_id` independently, so the synthetic id causes no mismatch; a provided or legacy id is preserved verbatim. Thanks @webtecnica. (#6168, #6008) - **A gateway command-approval card with no `approval_id` is now respondable.** When a gateway `approval.request` omits both `approval_id` and the legacy `id`, WebUI now synthesizes a non-empty `uuid4().hex` id instead of leaving it blank, so the approval card can be answered (matching the `#527` stable-uuid design). The downstream respond path resolves by `run_id` independently, so the synthetic id causes no mismatch; a provided or legacy id is preserved verbatim. Thanks @webtecnica. (#6168, #6008)
- **The update check no longer lets an unexpected exception escape the route.** `POST /api/updates/check` now wraps `check_for_updates()` in a defensive guard that returns a clean `500` (and logs) instead of propagating, giving the endpoint the same exception protection as its siblings. Defensive hardening only — it does not change the success path. Thanks @webtecnica. (#6180) - **The update check no longer lets an unexpected exception escape the route.** `POST /api/updates/check` now wraps `check_for_updates()` in a defensive guard that returns a clean `500` (and logs) instead of propagating, giving the endpoint the same exception protection as its siblings. Defensive hardening only — it does not change the success path. Thanks @webtecnica. (#6180)

View file

@ -570,6 +570,14 @@ def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, mode
error_classification["type"], error_classification["type"],
error_classification.get("hint", ""), error_classification.get("hint", ""),
) )
# Freeze turn duration before terminal cleanup clears pending_started_at (#6309)
_turn_duration_seconds = 0.0
try:
_pending_ts = getattr(session, 'pending_started_at', None)
if _pending_ts:
_turn_duration_seconds = max(0.0, time.time() - float(_pending_ts))
except Exception:
pass
_materialize_pending_user_turn_before_error(session) _materialize_pending_user_turn_before_error(session)
session.active_stream_id = None session.active_stream_id = None
session.pending_user_message = None session.pending_user_message = None
@ -588,6 +596,7 @@ def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, mode
) + (f"\n\n*{error_payload['hint']}*" if error_payload.get("hint") else ""), ) + (f"\n\n*{error_payload['hint']}*" if error_payload.get("hint") else ""),
"timestamp": int(time.time()), "timestamp": int(time.time()),
"_error": True, "_error": True,
"_turnDuration": round(_turn_duration_seconds, 3),
} }
if error_payload.get("details"): if error_payload.get("details"):
error_message["provider_details"] = error_payload["details"] error_message["provider_details"] = error_payload["details"]

View file

@ -6445,6 +6445,15 @@ def _snapshot_and_append_partial_on_error(session, stream_id) -> dict | None:
if _live_tools is not live_tool_calls: if _live_tools is not live_tool_calls:
_snap_tool_calls = list(_live_tools.get(stream_id, []) or []) _snap_tool_calls = list(_live_tools.get(stream_id, []) or [])
# Seal projected live tool rows so the durable scene cannot disagree with
# the settled terminal state (#6309). Any tool call that was still running
# at error time is marked as completed with the terminal-error metadata.
if _snap_tool_calls:
for _tc in _snap_tool_calls:
if isinstance(_tc, dict) and not _tc.get('done'):
_tc['done'] = True
_tc['_sealed_by_terminal_error'] = True
_partial_msg = _build_partial_message(_snap_partial_text, _snap_reasoning, _snap_tool_calls) _partial_msg = _build_partial_message(_snap_partial_text, _snap_reasoning, _snap_tool_calls)
if _partial_msg is None: if _partial_msg is None:
return None return None
@ -9449,6 +9458,14 @@ def _run_agent_streaming(
+ 'send a message, switch the model/provider, or fix the credentials.' + 'send a message, switch the model/provider, or fix the credentials.'
) )
_error_payload['hint'] = _err_hint _error_payload['hint'] = _err_hint
# Freeze turn duration before terminal cleanup clears pending_started_at (#6309)
_turn_duration_seconds = 0.0
try:
_pending_ts = getattr(s, 'pending_started_at', None)
if _pending_ts:
_turn_duration_seconds = max(0.0, time.time() - float(_pending_ts))
except Exception:
pass
_materialize_pending_user_turn_before_error(s) _materialize_pending_user_turn_before_error(s)
s.active_stream_id = None s.active_stream_id = None
s.pending_user_message = None s.pending_user_message = None
@ -9468,6 +9485,7 @@ def _run_agent_streaming(
'content': _error_content, 'content': _error_content,
'timestamp': int(time.time()), 'timestamp': int(time.time()),
'_error': True, '_error': True,
'_turnDuration': round(_turn_duration_seconds, 3),
} }
if _err_type == 'compression_exhausted': if _err_type == 'compression_exhausted':
_recovery = stamp_compression_exhausted_recovery( _recovery = stamp_compression_exhausted_recovery(
@ -10672,6 +10690,14 @@ def _run_agent_streaming(
+ 'send a message, switch the model/provider, or fix the credentials.' + 'send a message, switch the model/provider, or fix the credentials.'
) )
_error_payload['hint'] = _exc_hint _error_payload['hint'] = _exc_hint
# Freeze turn duration before terminal cleanup clears pending_started_at (#6309)
_turn_duration_seconds = 0.0
try:
_pending_ts = getattr(s, 'pending_started_at', None)
if _pending_ts:
_turn_duration_seconds = max(0.0, time.time() - float(_pending_ts))
except Exception:
pass
_materialize_pending_user_turn_before_error(s) _materialize_pending_user_turn_before_error(s)
s.active_stream_id = None s.active_stream_id = None
s.pending_user_message = None s.pending_user_message = None
@ -10687,6 +10713,7 @@ def _run_agent_streaming(
'content': f'**{_exc_label}:** {_error_payload.get("message") or err_str}' + (f'\n\n*{_exc_hint}*' if _exc_hint else ''), 'content': f'**{_exc_label}:** {_error_payload.get("message") or err_str}' + (f'\n\n*{_exc_hint}*' if _exc_hint else ''),
'timestamp': int(time.time()), 'timestamp': int(time.time()),
'_error': True, '_error': True,
'_turnDuration': round(_turn_duration_seconds, 3),
} }
if _exc_type == 'compression_exhausted': if _exc_type == 'compression_exhausted':
_recovery = stamp_compression_exhausted_recovery( _recovery = stamp_compression_exhausted_recovery(

View file

@ -11,6 +11,27 @@ This is the comprehensive Docker reference. For a 5-minute quickstart, see the [
| **Three-container** | Two-container PLUS the dashboard for monitoring. | `docker-compose.three-container.yml` | | **Three-container** | Two-container PLUS the dashboard for monitoring. | `docker-compose.three-container.yml` |
| **All-in-one image** (community fork — third-party, not maintained by us) | Podman 3.4 / multi-arch / supervisord-style preference. | [sunnysktsang/hermes-suite](https://github.com/sunnysktsang/hermes-suite) — see [#1399](https://github.com/nesquena/hermes-webui/issues/1399) for the original discussion | | **All-in-one image** (community fork — third-party, not maintained by us) | Podman 3.4 / multi-arch / supervisord-style preference. | [sunnysktsang/hermes-suite](https://github.com/sunnysktsang/hermes-suite) — see [#1399](https://github.com/nesquena/hermes-webui/issues/1399) for the original discussion |
### Available Docker tags
The WebUI Docker image is published to `ghcr.io/nesquena/hermes-webui` with these tags:
| Tag | Channel | Description |
|---|---|---|
| `:latest` | stable | The most recent stable release (from `v*` tags). Suitable for production. |
| `:experimental` | experimental | The most recent experimental release (from `exp-v*` tags). For early testing; may include breaking changes or unfinished features. Do not run in production. |
| `:X.Y` / `:X.Y.Z` | stable | Pinned stable releases (e.g., `:1.5`, `:1.5.0`). |
| `:X.Y` / `:X.Y.Z` | experimental | Pinned experimental releases — same version numbers but pushed from `exp-v*` tags. The `:experimental` floating tag always points at the latest of these. |
To track experimental builds in Docker Compose, use the `:experimental` tag:
```yaml
services:
hermes-webui:
image: ghcr.io/nesquena/hermes-webui:experimental
```
> **Note:** updating between `:experimental` builds requires `docker compose pull` followed by `docker compose up -d` — the floating tag is updated only when a new `exp-v*` release is pushed. Experimental builds are not pushed on every commit to the default branch.
> **Note (v0.14+):** If you use `docker-compose.three-container.yml`, both > **Note (v0.14+):** If you use `docker-compose.three-container.yml`, both
> `hermes-agent` and `hermes-dashboard` initialise from the same image and write > `hermes-agent` and `hermes-dashboard` initialise from the same image and write
> to the same `hermes-home` volume simultaneously. This can cause overlapping lock > to the same `hermes-home` volume simultaneously. This can cause overlapping lock

View file

@ -618,8 +618,9 @@ def test_gateway_chat_worker_persists_reasoning_and_tool_state_on_terminal_error
assert partial_message["_partial_tool_calls"] == [{ assert partial_message["_partial_tool_calls"] == [{
"name": "terminal", "name": "terminal",
"args": {}, "args": {},
"done": False, "done": True,
"tid": "call-1", "tid": "call-1",
"_sealed_by_terminal_error": True,
}] }]
apperrors = [item[1] for item in events if item[0] == "apperror"] apperrors = [item[1] for item in events if item[0] == "apperror"]
assert apperrors[-1]["session"]["messages"][-2]["reasoning"] == "Preview reasoning" assert apperrors[-1]["session"]["messages"][-2]["reasoning"] == "Preview reasoning"