* 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:
parent
10e5c52b10
commit
307b7fce5f
6 changed files with 78 additions and 2 deletions
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue