Merge pull request #7818 from nesquena/stage/0924-o
Gateway-backend turns survive a WebUI restart (#7785)
This commit is contained in:
7 files changed
+857
-57
No files matched your search
@@ -44,6 +44,15 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Gateway-backend turns survive a WebUI restart.** With the Gateway runs API enabled
|
||||
(`HERMES_WEBUI_CHAT_BACKEND=gateway` + `HERMES_WEBUI_GATEWAY_USE_RUNS_API=true`), the Gateway
|
||||
runs the turn, but restarting the WebUI still marked it interrupted, because the Gateway `run_id`
|
||||
only lived in process memory. The run is now saved on the session as soon as the Gateway admits
|
||||
it, with an `Idempotency-Key` so the Gateway keeps a durable record. On startup the WebUI
|
||||
reattaches: it follows `GET /v1/runs/{id}` until the run settles and writes the real final
|
||||
answer back. Text streamed before the restart isn't replayed; the settled answer replaces it.
|
||||
Stop on a reattached turn goes to the Gateway that owns the run. Thanks @carlotestor. (#7785)
|
||||
|
||||
- **Fewer self-inflicted console errors: git badge on CLI/subagent sessions, pollers after a
|
||||
profile switch, and the PWA startup preload.** Three separate sources of noise in DevTools, all
|
||||
caused by the WebUI itself. `/api/git-info` returned 404 for any session without a WebUI sidecar
|
||||
|
||||
+309
-53
@@ -47,6 +47,21 @@ _STREAM_RUN_IDS: dict[str, str] = {}
|
||||
_STREAM_RUN_LIFECYCLE: dict[str, dict[str, Any]] = {}
|
||||
_STREAM_RUN_STARTING_CONDITION = threading.Condition()
|
||||
GATEWAY_RUN_ID_WAIT_TIMEOUT = 5.0
|
||||
# stream_id -> (base_url, api_key) of the Gateway the stream's run lives on, for Stop
|
||||
# and approval replies; a reattached run's Gateway need not be the process profile's.
|
||||
_STREAM_ENDPOINTS: dict[str, tuple[str, str]] = {}
|
||||
|
||||
|
||||
def gateway_run_endpoint(run_id: str) -> tuple[str, str]:
|
||||
"""URL and key of the Gateway that owns run_id; the process Gateway if no live stream holds it."""
|
||||
run_id = str(run_id or "").strip()
|
||||
with _STREAM_RUN_STARTING_CONDITION:
|
||||
for stream_id, mapped in list(_STREAM_RUN_IDS.items()):
|
||||
if run_id and mapped == run_id and stream_id in _STREAM_ENDPOINTS:
|
||||
return _STREAM_ENDPOINTS[stream_id]
|
||||
from api.config import get_config
|
||||
|
||||
return _gateway_base_url(get_config()), _gateway_api_key()
|
||||
|
||||
|
||||
def _mark_gateway_run_starting(stream_id: str) -> None:
|
||||
@@ -565,24 +580,66 @@ def _gateway_runs_approval_event(payload: dict) -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
def _relay_gateway_run_approval(session_id, run_id, payload, base_url, api_key, *, put_gateway_event) -> None:
|
||||
"""Auto-approve or surface one runs-API approval request as a WebUI approval card."""
|
||||
approval_data = _gateway_runs_approval_event(payload)
|
||||
if not approval_data:
|
||||
return
|
||||
approval_data["run_id"] = run_id
|
||||
from api.config import gateway_supports_approval_identity_v1
|
||||
identity_v1 = bool(approval_data.get("_gateway_raw_approval_id_present")) and gateway_supports_approval_identity_v1(base_url, api_key)
|
||||
approval_data["_gateway_agent_identity_v1"] = identity_v1
|
||||
auto_approved, head, total = _settle_gateway_run_approval(
|
||||
session_id,
|
||||
approval_data,
|
||||
base_url,
|
||||
api_key,
|
||||
)
|
||||
if not auto_approved:
|
||||
put_gateway_event("approval", {**(head or approval_data), "pending_count": total})
|
||||
|
||||
|
||||
def _admit_gateway_run(url_runs, headers, run_body, stream_id) -> str:
|
||||
"""POST /v1/runs; the same stream and body return the originally admitted run id."""
|
||||
req = urllib.request.Request(
|
||||
url_runs,
|
||||
data=json.dumps(run_body).encode("utf-8"),
|
||||
# Durable run record on the gateway: GET /v1/runs/{id} survives either side restarting.
|
||||
headers={**headers, "Idempotency-Key": f"webui-{stream_id}"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
run_data = json.loads(resp.read(65536))
|
||||
run_id = str(run_data.get("run_id") or run_data.get("id") or "").strip()
|
||||
if not run_id:
|
||||
raise ValueError(f"Gateway runs API returned no run_id: {run_data!r}")
|
||||
return run_id
|
||||
|
||||
|
||||
def _gateway_run_headers(session_id, api_key) -> dict:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-Hermes-Session-Id": session_id,
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
headers["X-Hermes-Session-Key"] = f"webui:{session_id}"
|
||||
return headers
|
||||
|
||||
|
||||
def _run_gateway_runs_api_streaming(
|
||||
session_id, msg_text, model, workspace, stream_id,
|
||||
base_url, api_key, prefill_messages, body_extras,
|
||||
*, put_gateway_event, cancel_event,
|
||||
attachments=None, cfg=None, session=None,
|
||||
active_provider: str = "",
|
||||
on_run_id=None,
|
||||
):
|
||||
"""Submit via POST /v1/runs and relay SSE events including approval."""
|
||||
try:
|
||||
url_runs = f"{base_url.rstrip('/')}/v1/runs"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-Hermes-Session-Id": session_id,
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
headers["X-Hermes-Session-Key"] = f"webui:{session_id}"
|
||||
headers = _gateway_run_headers(session_id, api_key)
|
||||
message_content: Any = str(msg_text or "")
|
||||
if attachments:
|
||||
try:
|
||||
@@ -635,24 +692,19 @@ def _run_gateway_runs_api_streaming(
|
||||
run_body["instructions"] = "\n\n".join(part for part in instructions_parts if part)
|
||||
if conversation_history:
|
||||
run_body["conversation_history"] = conversation_history
|
||||
req = urllib.request.Request(
|
||||
url_runs,
|
||||
data=json.dumps(run_body).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
update_active_run(stream_id, phase="gateway-request")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
run_data = json.loads(resp.read(65536))
|
||||
run_id = str(run_data.get("run_id") or run_data.get("id") or "").strip()
|
||||
if not run_id:
|
||||
raise ValueError(f"Gateway runs API returned no run_id: {run_data!r}")
|
||||
# Persist the exact body first: a restart before the run id is saved replays this admission.
|
||||
if on_run_id is not None:
|
||||
on_run_id("", request=run_body)
|
||||
run_id = _admit_gateway_run(url_runs, headers, run_body, stream_id)
|
||||
except Exception:
|
||||
_finish_gateway_run_starting(stream_id)
|
||||
raise
|
||||
|
||||
usage: dict = {}
|
||||
_publish_gateway_run_id(stream_id, run_id)
|
||||
if on_run_id is not None:
|
||||
on_run_id(run_id)
|
||||
|
||||
url_events = f"{base_url.rstrip('/')}/v1/runs/{run_id}/events"
|
||||
headers_sse = dict(headers)
|
||||
@@ -683,22 +735,10 @@ def _run_gateway_runs_api_streaming(
|
||||
continue
|
||||
payload_event = str(payload.get("event") or payload.get("type") or sse_event).strip() or "message"
|
||||
if payload_event == "approval.request":
|
||||
approval_data = _gateway_runs_approval_event(payload)
|
||||
if approval_data:
|
||||
approval_data["run_id"] = run_id
|
||||
from api.config import gateway_supports_approval_identity_v1
|
||||
identity_v1 = bool(approval_data.get("_gateway_raw_approval_id_present")) and gateway_supports_approval_identity_v1(base_url, api_key)
|
||||
approval_data["_gateway_agent_identity_v1"] = identity_v1
|
||||
auto_approved, head, total = _settle_gateway_run_approval(
|
||||
session_id,
|
||||
approval_data,
|
||||
base_url,
|
||||
api_key,
|
||||
)
|
||||
if auto_approved:
|
||||
sse_event = "message"
|
||||
continue
|
||||
put_gateway_event("approval", {**(head or approval_data), "pending_count": total})
|
||||
_relay_gateway_run_approval(
|
||||
session_id, run_id, payload, base_url, api_key,
|
||||
put_gateway_event=put_gateway_event,
|
||||
)
|
||||
sse_event = "message"
|
||||
continue
|
||||
if payload_event in {"tool.started", "tool.completed", "reasoning.available"}:
|
||||
@@ -795,11 +835,7 @@ def stop_gateway_run(run_id: str) -> bool:
|
||||
run_id = str(run_id or "").strip()
|
||||
if not run_id:
|
||||
return False
|
||||
from api.config import get_config
|
||||
|
||||
cfg = get_config()
|
||||
base_url = _gateway_base_url(cfg)
|
||||
api_key = _gateway_api_key()
|
||||
base_url, api_key = gateway_run_endpoint(run_id)
|
||||
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
@@ -824,6 +860,180 @@ def stop_gateway_run(run_id: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
_GATEWAY_RUN_TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled", "interrupted"})
|
||||
GATEWAY_REATTACH_POLL_INTERVAL = 2.0
|
||||
# Consecutive unreachable polls tolerated before the reattached turn is failed (~5 min at 2s).
|
||||
GATEWAY_REATTACH_MAX_POLL_FAILURES = 150
|
||||
_REATTACH_SCAN_HEAD_BYTES = 16 * 1024
|
||||
|
||||
|
||||
def _record_gateway_run(session_id: str, stream_id: str, run_id: str, request=None, **extra) -> None:
|
||||
"""Persist the run id (or, before admission, the exact request) so a restarted WebUI can reattach."""
|
||||
try:
|
||||
with _get_session_agent_lock(session_id):
|
||||
session = get_session(session_id)
|
||||
if not _stream_writeback_is_current(session, stream_id):
|
||||
return
|
||||
session.gateway_run = {"run_id": run_id, "stream_id": stream_id, **extra}
|
||||
if not run_id:
|
||||
session.gateway_run["request"] = request
|
||||
session.save(touch_updated_at=False)
|
||||
except Exception:
|
||||
logger.warning("Failed to persist gateway run %s for session %s", run_id, session_id, exc_info=True)
|
||||
if not run_id:
|
||||
raise # never admit a run a restart could not recover
|
||||
|
||||
|
||||
def _get_gateway_run_status(base_url: str, api_key: str, run_id: str) -> dict:
|
||||
headers = {"Accept": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
req = urllib.request.Request(
|
||||
f"{base_url.rstrip('/')}/v1/runs/{urllib.parse.quote(run_id, safe='')}",
|
||||
headers=headers,
|
||||
method="GET",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
payload = json.loads(resp.read(1024 * 1024) or b"{}")
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _await_gateway_run_result(
|
||||
session_id, stream_id, run_id, base_url, api_key,
|
||||
*, put_gateway_event, cancel_event,
|
||||
):
|
||||
"""Poll a run started by a previous WebUI process; same return contract as _run_gateway_runs_api_streaming."""
|
||||
from api.route_approvals import settle_gateway_pending_run
|
||||
|
||||
_publish_gateway_run_id(stream_id, run_id)
|
||||
update_active_run(stream_id, phase="gateway-reattached")
|
||||
surfaced_approval_ids: set[str] = set()
|
||||
failures = 0
|
||||
while True:
|
||||
if cancel_event.is_set():
|
||||
put_gateway_event("cancel", {"message": "Cancelled by user"})
|
||||
return None, {}
|
||||
try:
|
||||
status = _get_gateway_run_status(base_url, api_key, run_id)
|
||||
failures = 0
|
||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||
code = getattr(exc, "code", None)
|
||||
if code == 404:
|
||||
raise RuntimeError("Gateway no longer has this run; its result could not be recovered after the WebUI restart.") from exc
|
||||
if code in (401, 403):
|
||||
raise RuntimeError(f"Gateway rejected the WebUI credentials (HTTP {code}) while reattaching to a run after the restart.") from exc
|
||||
failures += 1
|
||||
if failures >= GATEWAY_REATTACH_MAX_POLL_FAILURES:
|
||||
raise RuntimeError("Gateway became unreachable while waiting for a run that outlived the WebUI restart.") from exc
|
||||
cancel_event.wait(GATEWAY_REATTACH_POLL_INTERVAL)
|
||||
continue
|
||||
state = str(status.get("status") or "").strip().lower()
|
||||
approval = status.get("approval")
|
||||
if state == "waiting_for_approval" and isinstance(approval, dict):
|
||||
approval_key = str(approval.get("approval_id") or approval.get("id") or approval.get("timestamp") or "")
|
||||
if approval_key not in surfaced_approval_ids:
|
||||
surfaced_approval_ids.add(approval_key)
|
||||
_relay_gateway_run_approval(
|
||||
session_id, run_id, approval, base_url, api_key, put_gateway_event=put_gateway_event,
|
||||
)
|
||||
if state in _GATEWAY_RUN_TERMINAL_STATUSES:
|
||||
settle_gateway_pending_run(session_id, run_id, reason=f"Gateway run {state} before approval resolution")
|
||||
if state == "cancelled":
|
||||
put_gateway_event("cancel", {"message": "Cancelled by gateway"})
|
||||
return None, {}
|
||||
if state != "completed":
|
||||
raise RuntimeError(str(status.get("error") or f"Gateway run {state}"))
|
||||
output = str(status.get("output") or "")
|
||||
if output and stream_id in STREAM_PARTIAL_TEXT:
|
||||
STREAM_PARTIAL_TEXT[stream_id] = output
|
||||
return output, {k: v for k, v in _gateway_stream_usage(status).items() if v}
|
||||
cancel_event.wait(GATEWAY_REATTACH_POLL_INTERVAL)
|
||||
|
||||
|
||||
def resume_gateway_runs_after_restart() -> list[str]:
|
||||
"""Reattach pending turns whose gateway run outlived the previous WebUI process; never raises.
|
||||
|
||||
Call before serving so stale-pending repair does not mark these turns interrupted.
|
||||
"""
|
||||
from api import models as _models
|
||||
|
||||
try:
|
||||
candidates = _sidecars_with_active_stream(_models.SESSION_DIR)
|
||||
except Exception:
|
||||
logger.warning("gateway reattach: could not scan session sidecars", exc_info=True)
|
||||
return []
|
||||
resumed: list[str] = []
|
||||
for sid in candidates:
|
||||
try:
|
||||
if _resume_gateway_run_for_session(_models.Session.load_metadata_only(sid)):
|
||||
resumed.append(sid)
|
||||
except Exception:
|
||||
logger.warning("gateway reattach failed for session %s", sid, exc_info=True)
|
||||
if resumed:
|
||||
logger.info("Reattached %d gateway run(s) that outlived the previous WebUI process", len(resumed))
|
||||
return resumed
|
||||
|
||||
|
||||
def _sidecars_with_active_stream(session_dir) -> list[str]:
|
||||
"""Ids of sidecars (not the index, which can lag a save) whose header shows an active stream."""
|
||||
ids = []
|
||||
for path in sorted(session_dir.glob("*.json")):
|
||||
if path.name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
with path.open("rb") as fp:
|
||||
head = fp.read(_REATTACH_SCAN_HEAD_BYTES)
|
||||
except OSError:
|
||||
continue
|
||||
if b'"active_stream_id": null' not in head:
|
||||
ids.append(path.stem)
|
||||
return ids
|
||||
|
||||
|
||||
def _gateway_endpoint_for_profile(profile_name) -> tuple[str, str]:
|
||||
"""URL and key of the session's own profile, root included, never the process-active profile."""
|
||||
from api import profiles as _profiles
|
||||
from api.config import get_config_for_profile_home
|
||||
|
||||
home = _profiles.get_hermes_home_for_profile(str(profile_name or "").strip())
|
||||
environ = {k: v for k, v in os.environ.items() if k not in _profiles._loaded_profile_env_keys}
|
||||
environ.update(_profiles.filter_runtime_env_for_gateway_parity(_profiles.get_profile_runtime_env(home)))
|
||||
return _gateway_base_url(get_config_for_profile_home(home), environ), _gateway_api_key(environ)
|
||||
|
||||
|
||||
def _resume_gateway_run_for_session(session) -> bool:
|
||||
from api.config import create_stream_channel, register_session_writeback_owner, register_stream_owner
|
||||
|
||||
run = (session.gateway_run if session is not None else None) or {}
|
||||
stream_id = str(run.get("stream_id") or "")
|
||||
# A persisted run is the authority whatever the process default backend is.
|
||||
if not stream_id or not (run.get("run_id") or run.get("request")) or session.active_stream_id != stream_id:
|
||||
return False
|
||||
sid = session.session_id
|
||||
endpoint = _gateway_endpoint_for_profile(session.profile)
|
||||
with STREAMS_LOCK:
|
||||
if stream_id in STREAMS:
|
||||
return False
|
||||
STREAMS[stream_id] = create_stream_channel()
|
||||
register_stream_owner(stream_id, sid)
|
||||
register_session_writeback_owner(sid, stream_id)
|
||||
_mark_gateway_run_starting(stream_id)
|
||||
threading.Thread(
|
||||
target=_run_gateway_chat_streaming,
|
||||
args=(sid, session.pending_user_message or "", session.model, session.workspace,
|
||||
stream_id, list(session.pending_attachments or [])),
|
||||
kwargs={
|
||||
"model_provider": session.model_provider,
|
||||
"goal_related": bool(run.get("goal_related")),
|
||||
"regeneration": bool(run.get("regeneration")),
|
||||
"reattach_run": run,
|
||||
"reattach_endpoint": endpoint,
|
||||
},
|
||||
name=f"gateway-reattach-{stream_id[:12]}",
|
||||
daemon=True,
|
||||
).start()
|
||||
return True
|
||||
|
||||
def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, model_provider, terminal_error):
|
||||
from api.streaming import (
|
||||
_classify_provider_error,
|
||||
@@ -847,6 +1057,7 @@ def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, mode
|
||||
turn_duration = _terminal_turn_duration(session)
|
||||
_materialize_pending_user_turn_before_error(session)
|
||||
session.active_stream_id = None
|
||||
session.gateway_run = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = []
|
||||
session.pending_started_at = None
|
||||
@@ -890,14 +1101,32 @@ def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, mode
|
||||
return error_payload
|
||||
|
||||
|
||||
def _settle_gateway_cancelled_turn(session_id, stream_id) -> None:
|
||||
"""Keep the prompt and a cancel marker when the run ended cancelled and Stop did not already settle it."""
|
||||
from api.streaming import _persist_cancelled_turn
|
||||
|
||||
with _get_session_agent_lock(session_id):
|
||||
session = get_session(session_id)
|
||||
if not _stream_writeback_is_current(session, stream_id):
|
||||
return
|
||||
_persist_cancelled_turn(session, message="Cancelled by gateway")
|
||||
session.gateway_run = None
|
||||
session.save()
|
||||
|
||||
|
||||
def _stream_writeback_is_current(session: Any, stream_id: str) -> bool:
|
||||
return bool(stream_id and getattr(session, "active_stream_id", None) == stream_id)
|
||||
|
||||
|
||||
def _clear_gateway_pending_state(session: Any, stream_id: str) -> None:
|
||||
if not _stream_writeback_is_current(session, stream_id):
|
||||
# Cancel clears active_stream_id eagerly; still drop this stream's run record.
|
||||
if session is not None and (session.gateway_run or {}).get("stream_id") == stream_id:
|
||||
session.gateway_run = None
|
||||
session.save(touch_updated_at=False)
|
||||
return
|
||||
session.active_stream_id = None
|
||||
session.gateway_run = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = None
|
||||
session.pending_started_at = None
|
||||
@@ -927,6 +1156,8 @@ def _run_gateway_chat_streaming(
|
||||
model_provider=None,
|
||||
goal_related=False,
|
||||
regeneration=False,
|
||||
reattach_run=None,
|
||||
reattach_endpoint=None,
|
||||
):
|
||||
"""Bridge a WebUI chat turn through Hermes Gateway's API server.
|
||||
|
||||
@@ -1013,8 +1244,9 @@ def _run_gateway_chat_streaming(
|
||||
model=model,
|
||||
model_provider=model_provider,
|
||||
)
|
||||
base_url = _gateway_base_url(cfg)
|
||||
api_key = _gateway_api_key()
|
||||
base_url, api_key = reattach_endpoint or (_gateway_base_url(cfg), _gateway_api_key())
|
||||
with _STREAM_RUN_STARTING_CONDITION:
|
||||
_STREAM_ENDPOINTS[stream_id] = (base_url, api_key)
|
||||
try:
|
||||
from api.config import _main_model_request_overrides
|
||||
_gw_overrides = _main_model_request_overrides(
|
||||
@@ -1025,7 +1257,7 @@ def _run_gateway_chat_streaming(
|
||||
except Exception:
|
||||
_gw_overrides = {}
|
||||
_runs_api_enabled = _gateway_use_runs_api_enabled(cfg)
|
||||
_use_runs_api = _runs_api_enabled and gateway_supports_approval(base_url, api_key)
|
||||
_use_runs_api = bool(reattach_run) or (_runs_api_enabled and gateway_supports_approval(base_url, api_key))
|
||||
if not _use_runs_api and runs_api_pending_marked:
|
||||
_finish_gateway_run_starting(stream_id, result="fallback")
|
||||
runs_api_pending_marked = False
|
||||
@@ -1075,17 +1307,37 @@ def _run_gateway_chat_streaming(
|
||||
body_extras["reasoning_effort"] = reasoning_effort
|
||||
if _gw_overrides.get("service_tier"):
|
||||
body_extras["service_tier"] = _gw_overrides["service_tier"]
|
||||
record_run = lambda run_id, request=None: _record_gateway_run(
|
||||
session_id, stream_id, run_id, request=request,
|
||||
regeneration=bool(regeneration), goal_related=bool(goal_related),
|
||||
)
|
||||
try:
|
||||
final_text, usage = _run_gateway_runs_api_streaming(
|
||||
session_id, msg_text, model, workspace, stream_id,
|
||||
base_url, api_key, prefill_messages, body_extras,
|
||||
put_gateway_event=put_gateway_event,
|
||||
cancel_event=cancel_event,
|
||||
attachments=attachments,
|
||||
cfg=cfg,
|
||||
session=s,
|
||||
active_provider=(model_provider or ""),
|
||||
)
|
||||
if reattach_run:
|
||||
run_id = str(reattach_run.get("run_id") or "").strip()
|
||||
if not run_id:
|
||||
# Crashed between admission and saving the id: the same key returns the original run.
|
||||
run_id = _admit_gateway_run(
|
||||
f"{base_url.rstrip('/')}/v1/runs", _gateway_run_headers(session_id, api_key),
|
||||
reattach_run["request"], stream_id,
|
||||
)
|
||||
record_run(run_id)
|
||||
final_text, usage = _await_gateway_run_result(
|
||||
session_id, stream_id, run_id, base_url, api_key,
|
||||
put_gateway_event=put_gateway_event,
|
||||
cancel_event=cancel_event,
|
||||
)
|
||||
else:
|
||||
final_text, usage = _run_gateway_runs_api_streaming(
|
||||
session_id, msg_text, model, workspace, stream_id,
|
||||
base_url, api_key, prefill_messages, body_extras,
|
||||
put_gateway_event=put_gateway_event,
|
||||
cancel_event=cancel_event,
|
||||
attachments=attachments,
|
||||
cfg=cfg,
|
||||
session=s,
|
||||
active_provider=(model_provider or ""),
|
||||
on_run_id=record_run,
|
||||
)
|
||||
except Exception as exc:
|
||||
error_payload = _settle_gateway_terminal_error(
|
||||
session_id,
|
||||
@@ -1100,6 +1352,7 @@ def _run_gateway_chat_streaming(
|
||||
put_gateway_event("apperror", error_payload)
|
||||
return
|
||||
if final_text is None:
|
||||
_settle_gateway_cancelled_turn(session_id, stream_id)
|
||||
return
|
||||
else:
|
||||
# Legacy gateway path: emit unsupported approval notice once per session,
|
||||
@@ -1387,6 +1640,7 @@ def _run_gateway_chat_streaming(
|
||||
if len(current_display_rows) == 1:
|
||||
current_display_rows[0]["timestamp"] = user_msg["timestamp"]
|
||||
s.active_stream_id = None
|
||||
s.gateway_run = None
|
||||
s.pending_user_message = None
|
||||
s.pending_attachments = None
|
||||
s.pending_started_at = None
|
||||
@@ -1537,6 +1791,8 @@ def _run_gateway_chat_streaming(
|
||||
if runs_api_pending_marked and gateway_run_id_pending(stream_id):
|
||||
_finish_gateway_run_starting(stream_id)
|
||||
_clear_gateway_run_starting(stream_id)
|
||||
with _STREAM_RUN_STARTING_CONDITION:
|
||||
_STREAM_ENDPOINTS.pop(stream_id, None)
|
||||
unregister_stream_owner(stream_id)
|
||||
unregister_active_run(stream_id)
|
||||
# Release the writeback-owner entry the route layer registered for this
|
||||
|
||||
@@ -1422,6 +1422,7 @@ class Session:
|
||||
process_wakeup_pause=None,
|
||||
share_token=None,
|
||||
share_created_at=None,
|
||||
gateway_run=None,
|
||||
**kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]
|
||||
self.title = title
|
||||
@@ -1536,6 +1537,7 @@ class Session:
|
||||
self.process_wakeup_pause = process_wakeup_pause if isinstance(process_wakeup_pause, dict) else {}
|
||||
self.share_token = str(share_token).strip() if share_token else None
|
||||
self.share_created_at = share_created_at
|
||||
self.gateway_run = gateway_run if isinstance(gateway_run, dict) else None
|
||||
# #5854: a compact fingerprint of anchor_activity_scenes ({scene_key:
|
||||
# updated_at}) persisted BEFORE the messages array so the sidebar-poll
|
||||
# freshness check can compare scene freshness without parsing the full
|
||||
@@ -1616,6 +1618,7 @@ class Session:
|
||||
'enabled_toolsets', 'composer_draft',
|
||||
'process_wakeup_pause',
|
||||
'share_token', 'share_created_at',
|
||||
'gateway_run',
|
||||
]
|
||||
meta = {k: getattr(self, k, None) for k in METADATA_FIELDS}
|
||||
# #5854: message_count and a compact anchor-scene fingerprint go in the
|
||||
|
||||
+3
-4
@@ -27098,8 +27098,8 @@ def _relay_gateway_run_approval(
|
||||
Both the approval-card endpoint and the ordinary session-YOLO endpoint use
|
||||
this chokepoint so one tab cannot retire another tab's parked remote run.
|
||||
"""
|
||||
from api.config import gateway_supports_approval_identity_v1, get_config as _get_config
|
||||
from api.gateway_chat import _gateway_api_key, _gateway_base_url
|
||||
from api.config import gateway_supports_approval_identity_v1
|
||||
from api.gateway_chat import gateway_run_endpoint
|
||||
from api.runner_client import HttpRunnerClient, RunnerClientError
|
||||
|
||||
run_id = str(mirror.get("run_id") or "").strip()
|
||||
@@ -27141,8 +27141,7 @@ def _relay_gateway_run_approval(
|
||||
enable_yolo=enable_yolo,
|
||||
)
|
||||
|
||||
base_url = _gateway_base_url(_get_config())
|
||||
api_key = _gateway_api_key()
|
||||
base_url, api_key = gateway_run_endpoint(run_id)
|
||||
identity_v1 = bool(current_mirror.get(_GATEWAY_AGENT_IDENTITY_V1)) and (
|
||||
gateway_supports_approval_identity_v1(base_url, api_key)
|
||||
)
|
||||
|
||||
@@ -119,6 +119,8 @@ HERMES_WEBUI_GATEWAY_USE_RUNS_API=true \
|
||||
|
||||
Use this when the connected gateway advertises approval support and you want tool approval cards to appear in WebUI. Without `HERMES_WEBUI_GATEWAY_USE_RUNS_API=true`, gateway chat stays on the legacy chat-completions transport and approval-capable commands can remain pending in the agent without a WebUI approval card.
|
||||
|
||||
On the runs API path the turn is executed by the Gateway, so restarting WebUI does not stop it. WebUI stores the Gateway `run_id` on the pending turn (and submits it with an `Idempotency-Key` so the Gateway keeps a durable run record). On startup, WebUI reattaches to every such run by polling `GET /v1/runs/{run_id}` until it settles, then writes the real final answer into the session instead of a "Response interrupted" marker. Stop still cancels a reattached run, and a pending approval is shown again. Token-by-token output from before the restart is not replayed; the reattached turn shows only the final answer. If the Gateway no longer knows the run (for example, it restarted too and the run was interrupted), the turn ends with an error message instead. The legacy chat-completions transport cannot reattach: its turn ends when the WebUI process that holds the HTTP stream exits.
|
||||
|
||||
When YOLO is enabled for a gateway-backed browser session, WebUI approves every
|
||||
approval already parked for that session: Runs API prompts are relayed by their
|
||||
exact `run_id` and mirror token, and local/no-run waiters are all released. It
|
||||
|
||||
@@ -669,6 +669,7 @@ def main() -> None:
|
||||
|
||||
_abort_if_already_serving(HOST, PORT)
|
||||
httpd = QuietHTTPServer((HOST, PORT), Handler)
|
||||
from api.gateway_chat import resume_gateway_runs_after_restart; resume_gateway_runs_after_restart() # bound, not yet serving
|
||||
|
||||
from api.config import TLS_ENABLED, TLS_CERT, TLS_KEY
|
||||
scheme = 'https' if TLS_ENABLED else 'http'
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
"""A gateway-owned run must survive a WebUI restart instead of being marked interrupted."""
|
||||
from collections import OrderedDict
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
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 import profiles
|
||||
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"):
|
||||
"""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 = 1.0
|
||||
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 _saved(session_id):
|
||||
return json.loads((models.SESSION_DIR / f"{session_id}.json").read_text())
|
||||
|
||||
|
||||
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 = {}
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
if req.get_method() == "POST":
|
||||
captured["post_headers"] = dict(req.header_items())
|
||||
return io.BytesIO(b'{"run_id":"run_live"}')
|
||||
# The run id is durable before the first event is relayed.
|
||||
captured["persisted_at_events"] = _saved(s.session_id).get("gateway_run")
|
||||
return io.BytesIO(
|
||||
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}"
|
||||
# Only ids and flags are persisted, never a credential.
|
||||
assert captured["persisted_at_events"] == {
|
||||
"run_id": "run_live", "stream_id": stream_id, "regeneration": False, "goal_related": False,
|
||||
}
|
||||
saved = _saved(s.session_id)
|
||||
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 = _saved(sid)
|
||||
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_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")]
|
||||
assert _saved(sid)["messages"][-1]["content"] == "approved and done"
|
||||
|
||||
|
||||
def test_nothing_to_reattach_is_left_to_stale_pending_repair(isolated_sessions, monkeypatch):
|
||||
sid, stream_id = _orphaned_gateway_turn()
|
||||
s = models.Session.load(sid)
|
||||
s.gateway_run = None
|
||||
s.save()
|
||||
monkeypatch.setattr(gateway_chat, "_get_gateway_run_status", lambda *a, **k: pytest.fail("must not poll"))
|
||||
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 = _saved(sid)
|
||||
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"])
|
||||
|
||||
|
||||
def _poll_until_completed(monkeypatch):
|
||||
seen = []
|
||||
|
||||
def fake_status(base_url, api_key, run_id):
|
||||
seen.append((base_url, api_key, run_id))
|
||||
return {"run_id": run_id, "status": "completed", "output": "answer"}
|
||||
|
||||
monkeypatch.setattr(gateway_chat, "_get_gateway_run_status", fake_status)
|
||||
return seen
|
||||
|
||||
|
||||
@pytest.mark.parametrize("index_state", ["missing", "stale", "corrupt"])
|
||||
def test_reattach_does_not_depend_on_the_session_index(isolated_sessions, monkeypatch, index_state):
|
||||
sid, _stream_id = _orphaned_gateway_turn()
|
||||
index = isolated_sessions / "_index.json"
|
||||
if index_state == "missing":
|
||||
index.unlink(missing_ok=True)
|
||||
elif index_state == "stale":
|
||||
# Sidecar saved, index update lost: the row still shows no active stream.
|
||||
index.write_text(json.dumps([{"session_id": sid, "active_stream_id": None}]))
|
||||
else:
|
||||
index.write_text("{not json")
|
||||
_poll_until_completed(monkeypatch)
|
||||
|
||||
assert gateway_chat.resume_gateway_runs_after_restart() == [sid]
|
||||
_wait_for_reattach_threads()
|
||||
|
||||
saved = _saved(sid)
|
||||
assert saved["messages"][-1]["content"] == "answer"
|
||||
assert saved["active_stream_id"] is None
|
||||
|
||||
|
||||
def test_reattach_skips_idle_sidecars_without_parsing_them(isolated_sessions, monkeypatch):
|
||||
idle = new_session()
|
||||
idle.messages = [{"role": "user", "content": "x", "timestamp": 1.0}]
|
||||
idle.save()
|
||||
sid, _ = _orphaned_gateway_turn()
|
||||
loaded = []
|
||||
real_load = models.Session.load_metadata_only
|
||||
monkeypatch.setattr(
|
||||
models.Session, "load_metadata_only",
|
||||
classmethod(lambda cls, s, **kw: loaded.append(s) or real_load.__func__(cls, s, **kw)),
|
||||
)
|
||||
_poll_until_completed(monkeypatch)
|
||||
|
||||
assert gateway_chat.resume_gateway_runs_after_restart() == [sid]
|
||||
_wait_for_reattach_threads()
|
||||
assert idle.session_id not in loaded
|
||||
assert sid in loaded
|
||||
|
||||
|
||||
@pytest.mark.parametrize("default_backend", ["gateway", "local"])
|
||||
@pytest.mark.parametrize("session_profile, process_profile", [("default", "work"), ("work", "default")])
|
||||
def test_reattach_resolves_gateway_from_the_session_profile(
|
||||
isolated_sessions, tmp_path, monkeypatch, default_backend, session_profile, process_profile,
|
||||
):
|
||||
"""Poll the session profile's own gateway, root included, whatever profile the process restarted under."""
|
||||
root = tmp_path / "hermes"
|
||||
for name, home in (("default", root), ("work", root / "profiles" / "work")):
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / ".env").write_text(
|
||||
f"HERMES_WEBUI_GATEWAY_BASE_URL=http://{name}-gateway:8642\nHERMES_WEBUI_GATEWAY_API_KEY={name}-key\n"
|
||||
)
|
||||
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", root)
|
||||
monkeypatch.setattr(profiles, "_active_profile", process_profile)
|
||||
monkeypatch.setattr(profiles, "_loaded_profile_env_keys", set())
|
||||
monkeypatch.delenv("HERMES_WEBUI_GATEWAY_BASE_URL")
|
||||
monkeypatch.delenv("HERMES_WEBUI_GATEWAY_API_KEY", raising=False)
|
||||
profiles._reload_dotenv(root if process_profile == "default" else root / "profiles" / "work")
|
||||
if default_backend == "local":
|
||||
monkeypatch.delenv("HERMES_WEBUI_CHAT_BACKEND")
|
||||
monkeypatch.delenv("HERMES_WEBUI_GATEWAY_USE_RUNS_API")
|
||||
sid, stream_id = _orphaned_gateway_turn()
|
||||
s = models.Session.load(sid)
|
||||
s.profile = session_profile
|
||||
s.save(touch_updated_at=False)
|
||||
models.SESSIONS.clear()
|
||||
seen = _poll_until_completed(monkeypatch)
|
||||
|
||||
assert os.environ["HERMES_WEBUI_GATEWAY_API_KEY"] == f"{process_profile}-key"
|
||||
assert gateway_chat.resume_gateway_runs_after_restart() == [sid]
|
||||
_wait_for_reattach_threads()
|
||||
|
||||
assert {(b, k, r) for b, k, r in seen} == {
|
||||
(f"http://{session_profile}-gateway:8642", f"{session_profile}-key", "run_survivor"),
|
||||
}
|
||||
saved = _saved(sid)
|
||||
assert saved["messages"][-1]["content"] == "answer"
|
||||
assert saved["active_stream_id"] is None and saved["gateway_run"] is None
|
||||
|
||||
|
||||
def test_crash_after_admission_before_run_id_saved_replays_the_same_admission(isolated_sessions, monkeypatch):
|
||||
s = new_session()
|
||||
stream_id = "stream-admitted"
|
||||
s.active_stream_id = stream_id
|
||||
s.pending_user_message = "long task"
|
||||
s.pending_attachments = []
|
||||
s.pending_started_at = 1.0
|
||||
s.save()
|
||||
posts = []
|
||||
|
||||
class Crash(BaseException):
|
||||
pass
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
assert req.get_method() == "POST"
|
||||
posts.append((req.get_header("Idempotency-key"), req.data))
|
||||
return io.BytesIO(b'{"run_id":"run_original","replayed":%s}' % (b"true" if len(posts) > 1 else b"false"))
|
||||
|
||||
real_record = gateway_chat._record_gateway_run
|
||||
|
||||
def record_then_die(session_id, stream_id, run_id, **kw):
|
||||
if run_id:
|
||||
raise Crash # process killed after the POST response, before the id is saved
|
||||
real_record(session_id, stream_id, run_id, **kw)
|
||||
|
||||
monkeypatch.setattr(gateway_chat, "gateway_supports_approval", lambda *a, **k: True)
|
||||
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", fake_urlopen)
|
||||
monkeypatch.setattr(gateway_chat, "_record_gateway_run", record_then_die)
|
||||
with STREAMS_LOCK:
|
||||
STREAMS[stream_id] = create_stream_channel()
|
||||
with pytest.raises(Crash):
|
||||
gateway_chat._run_gateway_runs_api_streaming(
|
||||
s.session_id, "long task", "test-model", "/tmp", stream_id, "http://gateway.local", "", [], {},
|
||||
put_gateway_event=lambda *a: None, cancel_event=threading.Event(), session=s,
|
||||
on_run_id=lambda run_id, **kw: gateway_chat._record_gateway_run(s.session_id, stream_id, run_id, **kw),
|
||||
)
|
||||
monkeypatch.setattr(gateway_chat, "_record_gateway_run", real_record)
|
||||
models.SESSIONS.clear()
|
||||
with STREAMS_LOCK:
|
||||
STREAMS.pop(stream_id, None)
|
||||
ACTIVE_RUNS.pop(stream_id, None)
|
||||
assert _saved(s.session_id)["gateway_run"]["run_id"] == ""
|
||||
seen = _poll_until_completed(monkeypatch)
|
||||
|
||||
assert gateway_chat.resume_gateway_runs_after_restart() == [s.session_id]
|
||||
_wait_for_reattach_threads()
|
||||
|
||||
# Replayed with the same key and byte-identical body, so the gateway returns the original run.
|
||||
assert len(posts) == 2 and posts[0] == posts[1] == (f"webui-{stream_id}", posts[0][1])
|
||||
assert {r for _b, _k, r in seen} == {"run_original"}
|
||||
saved = _saved(s.session_id)
|
||||
assert saved["messages"][-1]["content"] == "answer"
|
||||
assert not any(m.get("_error") for m in saved["messages"])
|
||||
assert saved["active_stream_id"] is None and saved["gateway_run"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code, message", [
|
||||
(404, "could not be recovered after the WebUI restart"),
|
||||
(401, "HTTP 401"),
|
||||
(403, "HTTP 403"),
|
||||
])
|
||||
def test_reattach_http_rejection_settles_the_turn_with_an_error(isolated_sessions, monkeypatch, code, message):
|
||||
sid, _stream_id = _orphaned_gateway_turn()
|
||||
calls = []
|
||||
|
||||
def rejected(base_url, api_key, run_id):
|
||||
calls.append(run_id)
|
||||
raise urllib.error.HTTPError("http://gateway.local/v1/runs/x", code, "denied", Message(), io.BytesIO(b""))
|
||||
|
||||
monkeypatch.setattr(gateway_chat, "_get_gateway_run_status", rejected)
|
||||
assert gateway_chat.resume_gateway_runs_after_restart() == [sid]
|
||||
_wait_for_reattach_threads()
|
||||
|
||||
assert len(calls) == 1
|
||||
saved = _saved(sid)
|
||||
assert saved["messages"][-2]["content"] == "long task"
|
||||
assert saved["messages"][-1]["_error"] is True
|
||||
assert message in saved["messages"][-1]["content"]
|
||||
assert saved["active_stream_id"] is None and saved["gateway_run"] is None
|
||||
|
||||
|
||||
def test_failed_recovery_checkpoint_never_admits_the_run(isolated_sessions, monkeypatch):
|
||||
s = new_session()
|
||||
stream_id = "stream-unsaved"
|
||||
s.active_stream_id = stream_id
|
||||
s.pending_user_message = "hi"
|
||||
s.pending_attachments = []
|
||||
s.pending_started_at = 1.0
|
||||
s.save()
|
||||
monkeypatch.setattr(gateway_chat, "gateway_supports_approval", lambda *a, **k: True)
|
||||
monkeypatch.setattr(gateway_chat.urllib.request, "urlopen", lambda *a, **k: pytest.fail("admitted without a checkpoint"))
|
||||
monkeypatch.setattr(models.Session, "save", lambda self, **kw: (_ for _ in ()).throw(OSError("disk full")))
|
||||
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 stream_id not in STREAMS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stopped_by", ["user", "gateway"])
|
||||
def test_cancelled_replayed_admission_keeps_the_prompt_and_is_not_reattached(isolated_sessions, monkeypatch, stopped_by):
|
||||
sid, stream_id = _orphaned_gateway_turn(run_id="")
|
||||
s = models.Session.load(sid)
|
||||
s.gateway_run["request"] = {"input": "long task"}
|
||||
s.save(touch_updated_at=False)
|
||||
models.SESSIONS.clear()
|
||||
monkeypatch.setattr(gateway_chat, "_admit_gateway_run", lambda *a, **k: "run_replayed")
|
||||
state = "running" if stopped_by == "user" else "cancelled"
|
||||
monkeypatch.setattr(gateway_chat, "_get_gateway_run_status", lambda b, k, r: {"run_id": r, "status": state})
|
||||
monkeypatch.setattr(gateway_chat, "stop_gateway_run", lambda run_id: True)
|
||||
|
||||
assert gateway_chat.resume_gateway_runs_after_restart() == [sid]
|
||||
if stopped_by == "user":
|
||||
assert gateway_chat.wait_for_gateway_run_id(stream_id, 5.0) == (True, "run_replayed")
|
||||
assert streaming.cancel_stream(stream_id)
|
||||
_wait_for_reattach_threads()
|
||||
|
||||
saved = _saved(sid)
|
||||
assert [(m["role"], m["content"]) for m in saved["messages"][:3]] == [
|
||||
("user", "earlier"), ("assistant", "earlier reply"), ("user", "long task"),
|
||||
]
|
||||
assert len(saved["messages"]) == 4 and saved["messages"][3]["_error"] is True
|
||||
assert "Task cancelled" in saved["messages"][3]["content"]
|
||||
assert saved["active_stream_id"] is None and saved["gateway_run"] is None
|
||||
assert saved["pending_user_message"] is None
|
||||
models.SESSIONS.clear()
|
||||
assert gateway_chat.resume_gateway_runs_after_restart() == []
|
||||
|
||||
|
||||
def _two_profile_gateways(tmp_path, monkeypatch, process_profile):
|
||||
root = tmp_path / "hermes"
|
||||
for name, home in (("default", root), ("work", root / "profiles" / "work")):
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / ".env").write_text(
|
||||
f"HERMES_WEBUI_GATEWAY_BASE_URL=http://{name}-gateway:8642\nHERMES_WEBUI_GATEWAY_API_KEY={name}-key\n"
|
||||
)
|
||||
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", root)
|
||||
monkeypatch.setattr(profiles, "_active_profile", process_profile)
|
||||
monkeypatch.setattr(profiles, "_loaded_profile_env_keys", set())
|
||||
monkeypatch.delenv("HERMES_WEBUI_GATEWAY_BASE_URL")
|
||||
monkeypatch.delenv("HERMES_WEBUI_GATEWAY_API_KEY", raising=False)
|
||||
profiles._reload_dotenv(root if process_profile == "default" else root / "profiles" / "work")
|
||||
|
||||
|
||||
def _reattach_under_profile(session_profile, run_id):
|
||||
sid, stream_id = _orphaned_gateway_turn(run_id=run_id)
|
||||
s = models.Session.load(sid)
|
||||
s.profile = session_profile
|
||||
s.save(touch_updated_at=False)
|
||||
models.SESSIONS.clear()
|
||||
assert gateway_chat.resume_gateway_runs_after_restart() == [sid]
|
||||
assert gateway_chat.wait_for_gateway_run_id(stream_id, 5.0) == (True, run_id)
|
||||
return sid, stream_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("session_profile, process_profile", [("default", "work"), ("work", "default")])
|
||||
def test_stop_reaches_the_gateway_that_owns_a_reattached_run(
|
||||
isolated_sessions, tmp_path, monkeypatch, session_profile, process_profile,
|
||||
):
|
||||
_two_profile_gateways(tmp_path, monkeypatch, process_profile)
|
||||
monkeypatch.setattr(gateway_chat, "_get_gateway_run_status", lambda b, k, r: {"run_id": r, "status": "running"})
|
||||
sent = []
|
||||
|
||||
class _Resp(io.BytesIO):
|
||||
status = 200
|
||||
|
||||
class _Opener:
|
||||
def open(self, req, timeout=None):
|
||||
sent.append((req.full_url, req.get_header("Authorization")))
|
||||
resp = _Resp(b"{}")
|
||||
resp.geturl = lambda: req.full_url
|
||||
return resp
|
||||
|
||||
monkeypatch.setattr(gateway_chat.urllib.request, "build_opener", lambda *a: _Opener())
|
||||
_sid, stream_id = _reattach_under_profile(session_profile, "run_to_stop")
|
||||
|
||||
assert gateway_chat.stop_gateway_run("run_to_stop")
|
||||
assert streaming.cancel_stream(stream_id)
|
||||
_wait_for_reattach_threads()
|
||||
|
||||
assert sent == [
|
||||
(f"http://{session_profile}-gateway:8642/v1/runs/run_to_stop/stop", f"Bearer {session_profile}-key"),
|
||||
]
|
||||
assert stream_id not in gateway_chat._STREAM_ENDPOINTS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("session_profile, process_profile", [("default", "work"), ("work", "default")])
|
||||
def test_approval_reply_reaches_the_gateway_that_owns_a_reattached_run(
|
||||
isolated_sessions, tmp_path, monkeypatch, session_profile, process_profile,
|
||||
):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import api.route_approvals as approvals
|
||||
import api.runner_client as runner_client
|
||||
from api import config, routes
|
||||
|
||||
_two_profile_gateways(tmp_path, monkeypatch, process_profile)
|
||||
monkeypatch.setattr(gateway_chat, "_get_gateway_run_status", lambda b, k, r: {
|
||||
"run_id": r, "status": "waiting_for_approval",
|
||||
"approval": {"event": "approval.request", "approval_id": "appr-1", "command": "rm -rf build", "description": "d"},
|
||||
})
|
||||
monkeypatch.setattr(config, "gateway_supports_approval_identity_v1", lambda *a, **k: True)
|
||||
replies = []
|
||||
|
||||
def fake_respond(self, run_id, approval_id, choice):
|
||||
replies.append((self.base_url, self.api_key, run_id, approval_id, choice))
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(runner_client.HttpRunnerClient, "respond_approval", fake_respond)
|
||||
sid, stream_id = _reattach_under_profile(session_profile, "run_parked")
|
||||
try:
|
||||
for _ in range(500):
|
||||
if approvals.gateway_pending_mirror(sid, approval_id="appr-1", run_id="run_parked"):
|
||||
break
|
||||
threading.Event().wait(0.01)
|
||||
handler = MagicMock()
|
||||
handler.wfile = io.BytesIO()
|
||||
routes._handle_approval_respond(handler, {"session_id": sid, "choice": "once", "approval_id": "appr-1"})
|
||||
|
||||
handler.send_response.assert_called_with(200)
|
||||
assert replies == [(f"http://{session_profile}-gateway:8642", f"{session_profile}-key", "run_parked", "appr-1", "once")]
|
||||
finally:
|
||||
streaming.cancel_stream(stream_id)
|
||||
_wait_for_reattach_threads()
|
||||
approvals._pending.pop(sid, None)
|
||||
Reference in new issue
Block a user