diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0f65df5..028cf68b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,7 @@ on: push: tags: - 'v*' + - 'exp-v*' jobs: release: @@ -15,11 +16,22 @@ jobs: steps: - 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 - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: generate_release_notes: true + prerelease: ${{ steps.channel.outputs.is_stable == 'false' }} # Set up multi-arch build (QEMU + Buildx) - uses: docker/setup-qemu-action@v3 @@ -41,7 +53,9 @@ jobs: images: ghcr.io/${{ github.repository }} tags: | 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) - name: Build and push Docker image diff --git a/CHANGELOG.md b/CHANGELOG.md index 66c29ad9..41614fce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) +- **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) - **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) diff --git a/api/gateway_chat.py b/api/gateway_chat.py index dbadfbaf..06053fcb 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -570,6 +570,14 @@ def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, mode error_classification["type"], 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) session.active_stream_id = 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 ""), "timestamp": int(time.time()), "_error": True, + "_turnDuration": round(_turn_duration_seconds, 3), } if error_payload.get("details"): error_message["provider_details"] = error_payload["details"] diff --git a/api/streaming.py b/api/streaming.py index 701c81a8..d7f91ccd 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -6445,6 +6445,15 @@ def _snapshot_and_append_partial_on_error(session, stream_id) -> dict | None: if _live_tools is not live_tool_calls: _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) if _partial_msg is None: return None @@ -9449,6 +9458,14 @@ def _run_agent_streaming( + 'send a message, switch the model/provider, or fix the credentials.' ) _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) s.active_stream_id = None s.pending_user_message = None @@ -9468,6 +9485,7 @@ def _run_agent_streaming( 'content': _error_content, 'timestamp': int(time.time()), '_error': True, + '_turnDuration': round(_turn_duration_seconds, 3), } if _err_type == 'compression_exhausted': _recovery = stamp_compression_exhausted_recovery( @@ -10672,6 +10690,14 @@ def _run_agent_streaming( + 'send a message, switch the model/provider, or fix the credentials.' ) _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) s.active_stream_id = 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 ''), 'timestamp': int(time.time()), '_error': True, + '_turnDuration': round(_turn_duration_seconds, 3), } if _exc_type == 'compression_exhausted': _recovery = stamp_compression_exhausted_recovery( diff --git a/docs/docker.md b/docs/docker.md index e351507e..e4a8bdd0 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -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` | | **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 > `hermes-agent` and `hermes-dashboard` initialise from the same image and write > to the same `hermes-home` volume simultaneously. This can cause overlapping lock diff --git a/tests/test_webui_gateway_chat_backend.py b/tests/test_webui_gateway_chat_backend.py index db404a34..7140c501 100644 --- a/tests/test_webui_gateway_chat_backend.py +++ b/tests/test_webui_gateway_chat_backend.py @@ -618,8 +618,9 @@ def test_gateway_chat_worker_persists_reasoning_and_tool_state_on_terminal_error assert partial_message["_partial_tool_calls"] == [{ "name": "terminal", "args": {}, - "done": False, + "done": True, "tid": "call-1", + "_sealed_by_terminal_error": True, }] apperrors = [item[1] for item in events if item[0] == "apperror"] assert apperrors[-1]["session"]["messages"][-2]["reasoning"] == "Preview reasoning"