fix(gateway): reattach runs-API turns after a WebUI restart instead of marking them interrupted

A gateway-backed turn on the runs API executes in the Hermes Gateway, but the
run id only lived in process memory, so a WebUI restart orphaned the turn and
stale-pending repair appended 'Response interrupted' while the run finished
on the gateway.

- persist session.gateway_run {run_id, stream_id} as soon as POST /v1/runs returns
- send Idempotency-Key webui-<stream_id> so the gateway keeps a durable run record
- on startup (after bind, before serving) re-register the stream and follow
  GET /v1/runs/{id} until terminal; write back the real output, surface a
  parked approval once, keep Stop working, clear the record on every settlement
This commit is contained in:
carlotestor committed 2026-09-23 16:30:21 +00:00
1 parent 2ba3e40210
commit 7ba246bfc0
5 files changed
+500 -28

No files matched your search

@@ -0,0 +1,255 @@
"""A gateway-owned run must survive a WebUI restart instead of being marked interrupted."""
from collections import OrderedDict
import io
import json
import threading
import urllib.error
from email.message import Message
import pytest
import api.gateway_chat as gateway_chat
import api.models as models
import api.streaming as streaming
from api.config import ACTIVE_RUNS, STREAMS, STREAMS_LOCK, create_stream_channel
from api.models import new_session
@pytest.fixture
def isolated_sessions(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(models, "SESSIONS", OrderedDict())
monkeypatch.setenv("HERMES_WEBUI_CHAT_BACKEND", "gateway")
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_USE_RUNS_API", "1")
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://gateway.local")
monkeypatch.setattr(gateway_chat, "GATEWAY_REATTACH_POLL_INTERVAL", 0.01)
monkeypatch.setattr(gateway_chat, "_gateway_reasoning_effort_for_request", lambda *a, **k: None)
monkeypatch.setattr(streaming, "_load_webui_prefill_context", lambda cfg: {
"status": "not_configured", "source": "none", "label": "", "message_count": 0, "messages": [],
})
monkeypatch.setattr(streaming, "_prefill_messages_with_webui_context", lambda ctx, cfg: [])
return session_dir
def _orphaned_gateway_turn(run_id="run_survivor", stream_id="stream-before-restart", started_at=1.0):
"""Persist the sidecar exactly as a WebUI process leaves it when killed mid-run."""
s = new_session()
s.messages = [
{"role": "user", "content": "earlier", "timestamp": 0.5},
{"role": "assistant", "content": "earlier reply", "timestamp": 0.6},
]
s.active_stream_id = stream_id
s.pending_user_message = "long task"
s.pending_attachments = []
s.pending_started_at = started_at
s.pending_user_source = "webui"
s.gateway_run = {"run_id": run_id, "stream_id": stream_id, "regeneration": False, "goal_related": False}
s.save()
# Simulate the new process: nothing of the old run is in memory.
models.SESSIONS.clear()
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)
ACTIVE_RUNS.pop(stream_id, None)
return s.session_id, stream_id
def _wait_for_reattach_threads(timeout=10.0):
for thread in threading.enumerate():
if thread.name.startswith("gateway-reattach-"):
thread.join(timeout)
assert not thread.is_alive(), "reattach worker did not settle"
def test_runs_api_start_sends_idempotency_key_and_persists_run_id(isolated_sessions, monkeypatch):
s = new_session()
stream_id = "stream-live"
s.active_stream_id = stream_id
s.pending_user_message = "hi"
s.pending_attachments = []
s.pending_started_at = 123.0
s.save()
captured = {}
class _Resp:
def __init__(self, body=None, lines=None):
self._body, self._lines = body, lines or []
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self, *_a):
return self._body
def __iter__(self):
return iter(self._lines)
def fake_urlopen(req, timeout=None):
if req.get_method() == "POST":
captured["post_headers"] = dict(req.header_items())
return _Resp(body=b'{"run_id":"run_live"}')
# The run id is durable before the first event is relayed.
captured["persisted_at_events"] = json.loads(
(isolated_sessions / f"{s.session_id}.json").read_text()
).get("gateway_run")
return _Resp(lines=[
b'data: {"event":"message.delta","delta":"done"}\n',
b'data: {"event":"run.completed","output":"done"}\n',
b"data: [DONE]\n",
])
monkeypatch.setattr(gateway_chat, "gateway_supports_approval", lambda *a, **k: True)
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", fake_urlopen)
with STREAMS_LOCK:
STREAMS[stream_id] = create_stream_channel()
gateway_chat._run_gateway_chat_streaming(s.session_id, "hi", "test-model", "/tmp", stream_id, [])
assert captured["post_headers"]["Idempotency-key"] == f"webui-{stream_id}"
assert captured["persisted_at_events"]["run_id"] == "run_live"
assert captured["persisted_at_events"]["stream_id"] == stream_id
saved = json.loads((isolated_sessions / f"{s.session_id}.json").read_text())
assert saved["gateway_run"] is None
assert saved["active_stream_id"] is None
assert saved["messages"][-1]["content"] == "done"
def test_restart_reattaches_and_writes_back_real_answer(isolated_sessions, monkeypatch):
sid, stream_id = _orphaned_gateway_turn()
release = threading.Event()
polls = []
def fake_status(base_url, api_key, run_id):
polls.append(run_id)
if not release.is_set():
return {"run_id": run_id, "status": "running"}
return {
"run_id": run_id,
"status": "completed",
"output": "finished after the restart",
"usage": {"input_tokens": 7, "output_tokens": 3},
}
monkeypatch.setattr(gateway_chat, "_get_gateway_run_status", fake_status)
assert gateway_chat.resume_gateway_runs_after_restart() == [sid]
# While the run is still going, the turn is live: no stale-pending repair, reconnect works.
assert stream_id in STREAMS
assert gateway_chat.wait_for_gateway_run_id(stream_id, 5.0) == (True, "run_survivor")
live = models.get_session(sid)
assert live.active_stream_id == stream_id
assert not any(m.get("_error") for m in live.messages)
release.set()
_wait_for_reattach_threads()
saved = json.loads((isolated_sessions / f"{sid}.json").read_text())
assert [m["role"] for m in saved["messages"]] == ["user", "assistant", "user", "assistant"]
assert saved["messages"][2]["content"] == "long task"
assert saved["messages"][3]["content"] == "finished after the restart"
assert not any(m.get("_error") for m in saved["messages"])
assert saved["active_stream_id"] is None
assert saved["pending_user_message"] is None
assert saved["gateway_run"] is None
assert set(polls) == {"run_survivor"}
assert stream_id not in STREAMS
def test_reattach_reports_run_the_gateway_no_longer_knows(isolated_sessions, monkeypatch):
sid, _stream_id = _orphaned_gateway_turn(run_id="run_gone")
def fake_status(base_url, api_key, run_id):
raise urllib.error.HTTPError("http://gateway.local", 404, "not found", Message(), io.BytesIO(b"{}"))
monkeypatch.setattr(gateway_chat, "_get_gateway_run_status", fake_status)
assert gateway_chat.resume_gateway_runs_after_restart() == [sid]
_wait_for_reattach_threads()
saved = json.loads((isolated_sessions / f"{sid}.json").read_text())
assert saved["messages"][-2]["content"] == "long task"
assert saved["messages"][-1]["_error"] is True
assert "could not be recovered after the WebUI restart" in saved["messages"][-1]["content"]
assert saved["active_stream_id"] is None
assert saved["gateway_run"] is None
def test_reattach_surfaces_pending_approval_once(isolated_sessions, monkeypatch):
sid, stream_id = _orphaned_gateway_turn(run_id="run_parked")
relayed = []
calls = {"n": 0}
def fake_status(base_url, api_key, run_id):
calls["n"] += 1
if calls["n"] < 4:
return {"run_id": run_id, "status": "waiting_for_approval", "approval": {
"event": "approval.request", "approval_id": "appr-1", "command": "rm -rf build", "description": "delete",
}}
return {"run_id": run_id, "status": "completed", "output": "approved and done"}
monkeypatch.setattr(gateway_chat, "_get_gateway_run_status", fake_status)
monkeypatch.setattr(
gateway_chat, "_relay_gateway_run_approval",
lambda session_id, run_id, payload, *a, **k: relayed.append((session_id, run_id, payload["approval_id"])),
)
gateway_chat.resume_gateway_runs_after_restart()
_wait_for_reattach_threads()
assert relayed == [(sid, "run_parked", "appr-1")]
saved = json.loads((isolated_sessions / f"{sid}.json").read_text())
assert saved["messages"][-1]["content"] == "approved and done"
def test_reattach_is_default_off_without_gateway_backend(isolated_sessions, monkeypatch):
_orphaned_gateway_turn()
monkeypatch.delenv("HERMES_WEBUI_CHAT_BACKEND")
monkeypatch.setattr(
gateway_chat, "_get_gateway_run_status",
lambda *a, **k: pytest.fail("legacy backend must not poll the gateway"),
)
assert gateway_chat.resume_gateway_runs_after_restart() == []
def test_sidecar_without_gateway_run_is_left_to_stale_pending_repair(isolated_sessions, monkeypatch):
sid, stream_id = _orphaned_gateway_turn()
s = models.Session.load(sid)
assert s is not None
s.gateway_run = None
s.save()
monkeypatch.setattr(
gateway_chat, "_get_gateway_run_status",
lambda *a, **k: pytest.fail("no recorded run id: nothing to reattach"),
)
assert gateway_chat.resume_gateway_runs_after_restart() == []
assert stream_id not in STREAMS
def test_cancel_still_stops_a_reattached_run(isolated_sessions, monkeypatch):
sid, stream_id = _orphaned_gateway_turn(run_id="run_to_stop")
stopped = []
monkeypatch.setattr(
gateway_chat, "_get_gateway_run_status",
lambda base_url, api_key, run_id: {"run_id": run_id, "status": "running"},
)
monkeypatch.setattr(gateway_chat, "stop_gateway_run", lambda run_id: stopped.append(run_id) or True)
gateway_chat.resume_gateway_runs_after_restart()
# Same two steps /api/chat/cancel performs for a gateway-backed stream.
_structured, run_id = gateway_chat.wait_for_gateway_run_id(stream_id, 5.0)
assert gateway_chat.stop_gateway_run(run_id)
for _ in range(500):
if stream_id in gateway_chat.CANCEL_FLAGS:
break
threading.Event().wait(0.01)
assert streaming.cancel_stream(stream_id)
_wait_for_reattach_threads()
assert stopped == ["run_to_stop"]
saved = json.loads((isolated_sessions / f"{sid}.json").read_text())
assert saved["active_stream_id"] is None
assert saved["gateway_run"] is None
assert not any(m.get("content") == "long task" and m.get("role") == "assistant" for m in saved["messages"])