Merge pull request #1483 from nesquena/stage-269

v0.50.269 — Bootstrap supervisor fix (#1478) + #1473 follow-ups (#1479, #1480)
This commit is contained in:
nesquena-hermes 2026-05-02 11:14:23 -07:00 committed by GitHub
commit b8a346f421
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 939 additions and 33 deletions

1
.gitignore vendored
View file

@ -41,6 +41,7 @@ docs/*
!docs/ui-ux/
!docs/ui-ux/**
!docs/docker.md
!docs/supervisor.md
# Local-only PR review harness: rendering drivers, sample bank, fixtures.
# Used by Claude during deep reviews; never shared in the repo.

View file

@ -1,5 +1,15 @@
# Hermes Web UI -- Changelog
## [v0.50.269] — 2026-05-02
### Fixed (1 self-built + 2 contributor follow-ups)
- **`bootstrap.py` `--foreground` mode for process supervisors** (#1478, self-built, closes #1458 Bug #1) — the `bootstrap.py` double-fork pattern (`subprocess.Popen([python, "server.py"], start_new_session=True)` then exit 0) breaks every process supervisor. launchd / systemd / supervisord / runit / s6 see the parent exit, mark the program "completed," and respawn it — but the orphaned server still owns port 8787, so the new bootstrap fails to bind, exits non-zero, supervisor respawns again. Loop until something else crashes the orphan and the next respawn finds the port free. Reporter described this as "the agent fixes it eventually" — that's the loop intermittently succeeding. **Fix:** new `--foreground` flag (and supervisor-environment auto-detection via `INVOCATION_ID` / `JOURNAL_STREAM` / `NOTIFY_SOCKET` / `SUPERVISOR_ENABLED` / `XPC_SERVICE_NAME` / `HERMES_WEBUI_FOREGROUND`). In foreground mode, replace the bootstrap process image with `server.py` via `os.execv` so the supervisor sees the long-lived server as the original child. KeepAlive / Restart=always now work correctly. Plus 1 Opus pre-merge MUST-FIX (`_is_real_supervisor_value()` helper rejects macOS Terminal's noise values like `XPC_SERVICE_NAME=0` and `application.com.apple.Terminal.<UUID>` — without this, every Mac dev running interactive `./start.sh` would silently auto-promote to foreground mode, losing the /health probe and browser open) + 2 SHOULD-FIX (test env-var leakage cleanup, pre-execv `os.access(python_exe, os.X_OK)` guard so a non-executable launcher path raises a clear RuntimeError instead of OSError-then-respawn-loop). 44 regression tests + new `docs/supervisor.md` reference (runnable launchd plist + systemd `.service` + supervisord conf + diagnostic `lsof`/`ppid` recipe). **Bugs #2 (state.db FD leak) and #3 (HTTP-unhealthy wedge) remain open under #1458** awaiting diagnostic data. (`bootstrap.py`, `docs/supervisor.md`, `.gitignore`, `tests/test_bootstrap_foreground.py`)
- **`/api/sessions` payload missing `pending_user_message`** (#1479, by @Thanatos-Z) — surgical 6-LOC follow-up to v0.50.267 #1473. The frontend reload/sidebar recovery filter at `sessions.js:1342-1349` checks both `s.active_stream_id` AND `s.pending_user_message` to keep mid-restore sessions visible, but `Session.compact()` (the dict serialized into the `/api/sessions` payload) was missing `pending_user_message`. The filter only worked via the `active_stream_id` clause. In practice not user-visible because `active_stream_id` and `pending_user_message` are set/cleared atomically together (verified at `api/routes.py:4232-4240`), so any session with the latter also had the former. The fix prevents future drift if the atomicity invariant ever changes. (`api/models.py`, `static/i18n.js`, `tests/test_issue856_session_streaming_state.py`)
- **bfcache `pageshow` doesn't restore active session** (#1480, by @dso2ng) — when a browser restores the WebUI from bfcache (back/forward navigation), the frozen DOM is brought back without re-running boot. Sessions with `active_stream_id` or `pending_user_message` set looked stale in the active pane because the in-flight reattach logic (the v0.50.267 #1473 fix) only ran on fresh page loads. **Fix:** the pageshow handler now `await loadSession(S.session.session_id)` to refresh through the normal load path, then `await checkInflightOnBoot(...)` to reattach SSE. Tightened existing bfcache layout-restore tests via a shared `_pageshow_handler()` helper that walks the listener body via brace matching instead of the prior brittle `[ps_idx:ps_idx + 1600]` window. New `tests/test_1466_bfcache_inflight_reattach.py`. (`static/boot.js`, `tests/test_1045_bfcache_layout_restore.py`, `tests/test_1466_bfcache_inflight_reattach.py`)
## [v0.50.268] — 2026-05-02
### Fixed (contributor PR batch — 4 PRs)

View file

@ -3,7 +3,7 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: v0.50.268 (May 02, 2026) — 3800 tests collected
> Last updated: v0.50.269 (May 02, 2026) — 3847 tests collected
> Tests: `pytest tests/ --collect-only -q`
> Source: <repo>/

View file

@ -1835,8 +1835,8 @@ Bridged CLI sessions:
---
*Last updated: v0.50.268, May 02, 2026*
*Total automated tests collected: 3800*
*Last updated: v0.50.269, May 02, 2026*
*Total automated tests collected: 3847*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*

View file

@ -479,6 +479,7 @@ class Session:
# Sessions without a fork must not leak None — see test_session_lineage_metadata_api.
**({'parent_session_id': self.parent_session_id} if self.parent_session_id else {}),
'active_stream_id': self.active_stream_id,
'pending_user_message': self.pending_user_message,
'is_cli_session': self.is_cli_session,
'source_tag': self.source_tag,
'session_source': self.session_source,

View file

@ -208,9 +208,83 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Fail instead of attempting the official Hermes installer.",
)
parser.add_argument(
"--foreground",
action="store_true",
help=(
"Run server.py in this process (via os.execv) instead of spawning a "
"child. Use this under launchd / systemd / supervisord so the "
"supervisor sees the long-lived server as the original child. "
"Implies --no-browser. Skips the post-launch health probe — the "
"supervisor's own KeepAlive / Restart=on-failure handles liveness."
),
)
return parser.parse_args()
# Env vars whose presence indicates this process was launched by a supervisor
# that wants to manage the server's lifecycle (KeepAlive, Restart=always, etc.).
# When any is set, we auto-promote to --foreground so we don't double-fork.
#
# - INVOCATION_ID systemd (set on every service activation)
# - JOURNAL_STREAM systemd (set when stdio is wired to the journal)
# - NOTIFY_SOCKET systemd Type=notify, s6 sd_notify-style
# - XPC_SERVICE_NAME launchd (set to the Label of the running plist)
# - SUPERVISOR_ENABLED supervisord
# - HERMES_WEBUI_FOREGROUND explicit user opt-in (=1 / true / yes / on)
#
# Note on XPC_SERVICE_NAME: macOS launchd sets this in EVERY Terminal-launched
# shell too — typical values include "0" (truthy in Python!) and
# "application.com.apple.Terminal.<UUID>". A bare existence check would
# false-positive on every Mac dev machine running ./start.sh interactively.
# We narrow to launchd Label-style names (com.<reverse-dns>.<svc>) — those
# are real services. Verified with `launchctl getenv XPC_SERVICE_NAME` and
# Apple's documented launchd behavior.
_SUPERVISOR_ENV_VARS = (
"INVOCATION_ID",
"JOURNAL_STREAM",
"NOTIFY_SOCKET",
"XPC_SERVICE_NAME",
"SUPERVISOR_ENABLED",
)
def _is_real_supervisor_value(name: str, value: str) -> bool:
"""Filter out known-noise env-var values that aren't actual supervisors.
Most env vars in _SUPERVISOR_ENV_VARS are only set by the supervisor we
care about, so any non-empty value is meaningful. XPC_SERVICE_NAME is the
exception: macOS launchd sets it in every Terminal-spawned shell with
values like "0" or "application.com.apple.Terminal.<UUID>". A real
launchd-managed service has a reverse-DNS Label like "com.example.foo".
"""
if not value:
return False
if name == "XPC_SERVICE_NAME":
# Reject Apple's noise values; accept Label-style names.
if value == "0":
return False
if value.startswith("application."):
return False
return True
def _detect_supervisor() -> str | None:
"""Return the name of the detected supervisor env var, or None.
Pure inspection of os.environ — no side effects. Returned name is the env
var that triggered detection, useful for log messages and for tests.
"""
explicit = os.environ.get("HERMES_WEBUI_FOREGROUND", "").strip().lower()
if explicit in ("1", "true", "yes", "on"):
return "HERMES_WEBUI_FOREGROUND"
for name in _SUPERVISOR_ENV_VARS:
value = os.environ.get(name, "")
if _is_real_supervisor_value(name, value):
return name
return None
def main() -> int:
args = parse_args()
ensure_supported_platform()
@ -229,21 +303,59 @@ def main() -> int:
os.getenv("HERMES_WEBUI_STATE_DIR", str(Path.home() / ".hermes" / "webui"))
).expanduser()
state_dir.mkdir(parents=True, exist_ok=True)
log_path = state_dir / f"bootstrap-{args.port}.log"
env = os.environ.copy()
env["HERMES_WEBUI_HOST"] = args.host
env["HERMES_WEBUI_PORT"] = str(args.port)
env.setdefault("HERMES_WEBUI_STATE_DIR", str(state_dir))
# Mutate os.environ so child (or post-execv) inherits the resolved values.
os.environ["HERMES_WEBUI_HOST"] = args.host
os.environ["HERMES_WEBUI_PORT"] = str(args.port)
os.environ.setdefault("HERMES_WEBUI_STATE_DIR", str(state_dir))
if agent_dir:
env["HERMES_WEBUI_AGENT_DIR"] = str(agent_dir)
os.environ["HERMES_WEBUI_AGENT_DIR"] = str(agent_dir)
server_cwd = str(agent_dir or REPO_ROOT)
server_path = str(REPO_ROOT / "server.py")
# --foreground (or auto-detected supervisor): replace this process with the
# server. The supervisor sees the long-lived server as the original child,
# so KeepAlive / Restart=always / autorestart=true work correctly. No
# health probe — the supervisor's own restart-on-exit handles liveness.
foreground_reason = "--foreground" if args.foreground else _detect_supervisor()
if foreground_reason:
info(
f"Starting Hermes Web UI on http://{args.host}:{args.port} "
f"(foreground mode: {foreground_reason})"
)
try:
os.chdir(server_cwd)
except OSError as exc:
raise RuntimeError(
f"Could not chdir to {server_cwd!r} before exec: {exc}"
) from exc
# Defensive check: if python_exe is missing or non-executable, execv
# raises OSError, the wrapper catches and SystemExit(1)s, and the
# supervisor restarts — looping forever, exactly the failure mode this
# PR is meant to eliminate. Convert to a single visible error.
if not os.access(python_exe, os.X_OK):
raise RuntimeError(
f"Python interpreter at {python_exe!r} is not executable. "
f"Set HERMES_WEBUI_PYTHON to a working interpreter or fix "
f"the agent venv at {agent_dir}."
)
# os.execv replaces the current process image. Anything after this line
# only runs if execv itself fails (it raises OSError on failure).
os.execv(python_exe, [python_exe, server_path])
# Unreachable — execv either replaces the process or raises.
raise RuntimeError("os.execv returned unexpectedly")
# Default (legacy) path: spawn the server as a detached child, probe
# /health, then return. Suitable for an interactive `bash start.sh` run.
log_path = state_dir / f"bootstrap-{args.port}.log"
info(f"Starting Hermes Web UI on http://{args.host}:{args.port}")
with log_path.open("ab") as log_file:
proc = subprocess.Popen(
[python_exe, str(REPO_ROOT / "server.py")],
cwd=str(agent_dir or REPO_ROOT),
env=env,
[python_exe, server_path],
cwd=server_cwd,
env=os.environ.copy(),
stdout=log_file,
stderr=subprocess.STDOUT,
start_new_session=True,

237
docs/supervisor.md Normal file
View file

@ -0,0 +1,237 @@
# Running Hermes Web UI under a process supervisor
Use a process supervisor (launchd, systemd, supervisord, runit, s6) when you
want the Web UI to start at boot, restart on crash, or be managed alongside
other services.
## TL;DR
Pass ``--foreground`` to ``bootstrap.py`` (or ``bash start.sh``):
```bash
bash start.sh --foreground
```
Or set ``HERMES_WEBUI_FOREGROUND=1`` in the environment. The Web UI will
auto-detect launchd / systemd / supervisord even without the flag, but being
explicit is safer.
## Why ``--foreground`` matters
Without it, ``bootstrap.py`` does this:
1. Spawn ``server.py`` as a detached subprocess (``start_new_session=True``)
2. Probe ``/health`` until the server is up
3. Exit 0
That works for an interactive shell run (``./start.sh`` returns to your
prompt with the server alive in the background). It is **broken** under any
process supervisor: the supervisor sees its tracked PID exit, marks the job
as completed, and respawns ``bootstrap.py``. The respawn fails to bind port
8787 (the orphaned server still has it), exits non-zero, supervisor
respawns again — loop.
In foreground mode, ``bootstrap.py`` does its setup work and then calls
``os.execv`` to replace its own process with ``server.py``. The supervisor
sees the long-lived server as the original child. ``KeepAlive=true`` /
``Restart=always`` work correctly.
## launchd (macOS)
``~/Library/LaunchAgents/com.example.hermes-webui.plist``:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.hermes-webui</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/yourname/hermes-webui/start.sh</string>
<string>--foreground</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/yourname/hermes-webui</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/yourname/.hermes/webui/launchd-stdout.log</string>
<key>StandardErrorPath</key>
<string>/Users/yourname/.hermes/webui/launchd-stderr.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>/Users/yourname</string>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
```
Load:
```bash
launchctl load ~/Library/LaunchAgents/com.example.hermes-webui.plist
launchctl print gui/$(id -u)/com.example.hermes-webui # check state
```
Reload after editing the plist:
```bash
launchctl unload ~/Library/LaunchAgents/com.example.hermes-webui.plist
launchctl load ~/Library/LaunchAgents/com.example.hermes-webui.plist
```
launchd sets ``XPC_SERVICE_NAME`` automatically, so even without the
``--foreground`` argument the Web UI will auto-promote to foreground mode.
The flag is still recommended as documentation of intent.
## systemd (Linux)
``~/.config/systemd/user/hermes-webui.service``:
```ini
[Unit]
Description=Hermes Web UI
After=network.target
[Service]
Type=simple
WorkingDirectory=%h/hermes-webui
ExecStart=/bin/bash %h/hermes-webui/start.sh --foreground
Restart=on-failure
RestartSec=5
# Optional: route stdout/stderr to journald instead of files
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=default.target
```
Enable + start:
```bash
systemctl --user daemon-reload
systemctl --user enable --now hermes-webui.service
journalctl --user -u hermes-webui.service -f
```
systemd sets ``INVOCATION_ID`` and ``JOURNAL_STREAM`` (when stdio is wired to
the journal), both of which auto-promote to foreground mode.
## supervisord (cross-platform)
``/etc/supervisor/conf.d/hermes-webui.conf``:
```ini
[program:hermes-webui]
command=/bin/bash /home/youruser/hermes-webui/start.sh --foreground
directory=/home/youruser/hermes-webui
user=youruser
autostart=true
autorestart=true
stopsignal=TERM
stopwaitsecs=10
stdout_logfile=/var/log/hermes-webui.out.log
stderr_logfile=/var/log/hermes-webui.err.log
environment=HOME="/home/youruser",PATH="/usr/local/bin:/usr/bin:/bin"
```
Reload + start:
```bash
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status hermes-webui
```
supervisord sets ``SUPERVISOR_ENABLED``, which auto-promotes to foreground
mode.
## Auto-detected env vars (full list)
These trigger ``--foreground`` behavior even when the flag is not passed:
| Env var | Set by | Notes |
|---|---|---|
| ``INVOCATION_ID`` | systemd | Set on every service activation |
| ``JOURNAL_STREAM`` | systemd | Set when stdio is wired to journald |
| ``NOTIFY_SOCKET`` | systemd ``Type=notify`` / s6 | sd_notify-style notification socket |
| ``XPC_SERVICE_NAME`` | launchd | Set to the plist Label — narrowed to ``com.<rdns>.<svc>`` form (see below) |
| ``SUPERVISOR_ENABLED`` | supervisord | Always set under supervisord |
| ``HERMES_WEBUI_FOREGROUND`` | you | Explicit opt-in; accepts ``1`` / ``true`` / ``yes`` / ``on`` |
### XPC_SERVICE_NAME noise filter
macOS launchd sets ``XPC_SERVICE_NAME`` in **every Terminal-spawned shell**,
not just real services. Typical noise values:
- ``0`` — set on launchd descendants generally
- ``application.com.apple.Terminal.<UUID>`` — Terminal.app shells
- ``application.com.googlecode.iterm2`` — iTerm2
- ``application.com.microsoft.VSCode`` — VSCode integrated terminal
A bare existence check on this var would auto-promote interactive
``./start.sh`` runs to foreground mode on every Mac dev machine, breaking
the most common installation path. We narrow detection to launchd
**Label-style** names (typically reverse-DNS like ``com.example.foo``).
Real launchd plists always use this form. If you ever see
``XPC_SERVICE_NAME=0`` in your service environment, the auto-detect will
ignore it — set ``HERMES_WEBUI_FOREGROUND=1`` or pass ``--foreground``
explicitly to be safe.
### Supervisors that are NOT auto-detected
The following set no env var that we can reliably detect. Pass
``--foreground`` (or ``HERMES_WEBUI_FOREGROUND=1``) explicitly:
- **runit** (without sd_notify) — pure runit chains
- **daemontools** / ``svc``
- **PM2** (Node.js process manager occasionally repurposed for Python)
- **Foreman** / **Honcho** (Procfile-style)
- **Docker** with a custom CMD entrypoint that doesn't already use ``exec``
- **Custom shell-script supervisors** that fork-and-wait
If your supervisor isn't in the auto-detect list and you see the orphan-PID
respawn loop, set ``HERMES_WEBUI_FOREGROUND=1`` in the service environment.
## Diagnostic recipe
If the Web UI keeps getting respawned and you suspect the double-fork loop:
```bash
# Check the running PID for the server
lsof -iTCP:8787 -sTCP:LISTEN
# Get its parent — should be the supervisor itself, NOT init (PID 1)
PID=$(lsof -tiTCP:8787 -sTCP:LISTEN)
ps -p "$PID" -o pid,ppid,cmd
ps -p "$(ps -o ppid= -p "$PID" | tr -d ' ')" -o pid,cmd
```
A healthy foreground-mode setup looks like:
```
PID PPID CMD
12345 6789 /path/to/python /path/to/server.py
6789 1 /sbin/launchd # or /usr/lib/systemd/systemd, etc.
```
If PPID is ``1`` (init) when it should be the supervisor, the orphan-server
loop is happening — re-check that ``--foreground`` (or one of the env vars)
is reaching the process.

View file

@ -1357,7 +1357,7 @@ function applyBotName(){
// sync whenever the page is restored from cache (`event.persisted === true`).
// Fix #1045: also re-run topbar/workspace/panel state so the rail and layout
// chrome aren't left in the stale bfcache snapshot.
window.addEventListener('pageshow', (event) => {
window.addEventListener('pageshow', async (event) => {
if (!event.persisted) return; // fresh loads are handled by the IIFE above
const _srch = document.getElementById('sessionSearch');
if (_srch) _srch.value = '';
@ -1367,6 +1367,17 @@ window.addEventListener('pageshow', (event) => {
if (typeof closeReasoningDropdown === 'function') try { closeReasoningDropdown(); } catch (_) {}
if (typeof closeWsDropdown === 'function') try { closeWsDropdown(); } catch (_) {}
if (typeof closeProfileDropdown === 'function') try { closeProfileDropdown(); } catch (_) {}
// BFCache restores the frozen DOM without rerunning boot. Refresh the active
// session through the normal load path so in-flight sessions with
// active_stream_id / pending_user_message can reattach like a reload restore.
if (S.session && S.session.session_id && typeof loadSession === 'function') {
try {
await loadSession(S.session.session_id);
if (S.session && S.session.session_id && typeof checkInflightOnBoot === 'function') {
try { await checkInflightOnBoot(S.session.session_id); } catch (_) {}
}
} catch (_) {}
}
// Re-synchronise layout chrome that the boot IIFE sets up but bfcache
// doesn't re-run. Each call is guarded so missing helpers degrade silently.
if (typeof syncTopbar === 'function') try { syncTopbar(); } catch (_) {}

View file

@ -7529,6 +7529,7 @@ function t(key, ...args) {
if (val === undefined) return key; // final fallback: return key itself
if (typeof val === 'function') return val(...args);
if (args.length) {
// Locale strings can use numbered placeholders like {0} and {1}.
return String(val).replace(/\{(\d+)\}/g, (match, idx) => (
Object.prototype.hasOwnProperty.call(args, idx) ? String(args[idx]) : match
));

View file

@ -20,14 +20,30 @@ def _boot_js() -> str:
return (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
def _pageshow_handler(src: str) -> str:
marker = "window.addEventListener('pageshow'"
ps_idx = src.find(marker)
assert ps_idx != -1, "pageshow listener not found in boot.js"
brace = src.find("=> {", ps_idx)
assert brace != -1, "pageshow listener body not found"
brace = src.find("{", brace)
depth = 1
i = brace + 1
while i < len(src) and depth:
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
i += 1
assert depth == 0, "pageshow listener body did not close"
return src[brace + 1 : i - 1]
class TestBfcacheLayoutRestore:
def test_pageshow_calls_sync_topbar(self):
"""pageshow handler must call syncTopbar() on bfcache restore."""
src = _boot_js()
# Find the pageshow listener block
ps_idx = src.find("window.addEventListener('pageshow'")
assert ps_idx != -1, "pageshow listener not found in boot.js"
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
assert "syncTopbar" in handler_body, (
"pageshow handler must call syncTopbar() to restore topbar state after bfcache"
)
@ -35,8 +51,7 @@ class TestBfcacheLayoutRestore:
def test_pageshow_calls_sync_workspace_panel_state(self):
"""pageshow handler must call syncWorkspacePanelState()."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
assert "syncWorkspacePanelState" in handler_body, (
"pageshow handler must call syncWorkspacePanelState() on bfcache restore"
)
@ -45,8 +60,7 @@ class TestBfcacheLayoutRestore:
def test_pageshow_calls_start_gateway_sse(self):
"""pageshow handler must call startGatewaySSE() to reconnect the dead SSE connection."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
assert "startGatewaySSE" in handler_body, (
"pageshow handler must restart gateway SSE (bfcache-persisted connections are dead)"
)
@ -54,8 +68,7 @@ class TestBfcacheLayoutRestore:
def test_pageshow_still_clears_session_search(self):
"""pageshow handler must still clear #sessionSearch (original #822 fix preserved)."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
assert "sessionSearch" in handler_body, (
"pageshow handler must still clear #sessionSearch (regression: #822 fix must be preserved)"
)
@ -63,8 +76,7 @@ class TestBfcacheLayoutRestore:
def test_pageshow_still_calls_render_session_list_from_cache(self):
"""pageshow handler must still call renderSessionListFromCache()."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
assert "renderSessionListFromCache" in handler_body, (
"pageshow handler must still call renderSessionListFromCache() (regression: #822 fix)"
)
@ -73,8 +85,7 @@ class TestBfcacheLayoutRestore:
"""pageshow handler must NOT call _initResizePanels() — bfcache
preserves event listeners so re-attaching them stacks duplicates."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
assert "_initResizePanels" not in handler_body, (
"pageshow handler must not call _initResizePanels() — it stacks "
"duplicate mousedown listeners on every bfcache restore"
@ -83,8 +94,7 @@ class TestBfcacheLayoutRestore:
def test_new_calls_are_guarded_with_typeof(self):
"""New calls in the pageshow handler must be typeof-guarded for safe degradation."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
# Each of the new calls must be guarded
for fn in ("syncTopbar", "syncWorkspacePanelState", "startGatewaySSE",
"closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
@ -95,8 +105,7 @@ class TestBfcacheLayoutRestore:
def test_pageshow_closes_all_dropdowns(self):
"""pageshow handler must close all known dropdowns to reset frozen bfcache popover state."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
for fn in ("closeModelDropdown", "closeReasoningDropdown", "closeWsDropdown", "closeProfileDropdown"):
assert fn in handler_body, (
f"pageshow handler must call {fn}() to dismiss any dropdown left open by bfcache"
@ -105,8 +114,7 @@ class TestBfcacheLayoutRestore:
def test_dropdowns_closed_before_layout_sync(self):
"""Dropdown closes must come before layout sync calls (clean state first)."""
src = _boot_js()
ps_idx = src.find("window.addEventListener('pageshow'")
handler_body = src[ps_idx:ps_idx + 1600]
handler_body = _pageshow_handler(src)
close_idx = handler_body.find("closeModelDropdown")
sync_idx = handler_body.find("syncTopbar")
assert close_idx != -1 and sync_idx != -1, "Both close and sync calls must be present"

View file

@ -0,0 +1,66 @@
"""Regression coverage for BFCache restore of an in-flight session (#1466).
A browser `pageshow` restore from BFCache does not re-run the boot IIFE. After
#1473, normal reload restores in-flight sessions through `loadSession()` and
`checkInflightOnBoot()`. BFCache restore should align with that path for the
currently viewed session instead of only refreshing layout chrome/sidebar cache.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
def _pageshow_handler() -> str:
marker = "window.addEventListener('pageshow'"
start = BOOT_JS.find(marker)
assert start != -1, "pageshow listener not found in boot.js"
brace = BOOT_JS.find("=> {", start)
assert brace != -1, "pageshow listener body not found"
brace = BOOT_JS.find("{", brace)
depth = 1
i = brace + 1
while i < len(BOOT_JS) and depth:
if BOOT_JS[i] == "{":
depth += 1
elif BOOT_JS[i] == "}":
depth -= 1
i += 1
assert depth == 0, "pageshow listener body did not close"
return BOOT_JS[brace + 1 : i - 1]
def test_pageshow_restores_active_inflight_session_through_load_session():
"""BFCache restore should refresh active session metadata and reattach stream.
The normal boot/reload path now calls loadSession(saved), which handles
active_stream_id / pending_user_message and reattaches via attachLiveStream().
The BFCache pageshow path must call the same loadSession path for the current
active session because the boot IIFE does not run again.
"""
body = _pageshow_handler()
assert "S.session" in body and "S.session.session_id" in body
assert "loadSession(S.session.session_id" in body
def test_pageshow_checks_persisted_inflight_marker_after_restoring_session():
"""BFCache restore should preserve the reload recovery marker behavior.
If localStorage still has an inflight marker, the pageshow path should reuse
checkInflightOnBoot() after restoring the active session, matching the normal
reload path without adding new server-side persistence behavior.
"""
body = _pageshow_handler()
load_pos = body.find("loadSession(S.session.session_id")
check_pos = body.find("checkInflightOnBoot(S.session.session_id")
assert load_pos != -1
assert check_pos != -1
assert load_pos < check_pos
def test_pageshow_active_session_restore_is_guarded_and_non_blocking():
"""pageshow handler must degrade safely when optional helpers are absent."""
body = _pageshow_handler()
assert "typeof loadSession === 'function'" in body
assert "typeof checkInflightOnBoot === 'function'" in body
assert "catch" in body[body.find("loadSession(S.session.session_id") : body.find("startGatewaySSE")]

View file

@ -0,0 +1,455 @@
"""
Tests for bootstrap.py --foreground / supervisor auto-detect (issue #1458, Bug #1).
Background
----------
Issue #1458 reports: under launchd / systemd / supervisord with KeepAlive=true
or Restart=always, bootstrap.py exits after spawning the server child via
``Popen + wait_for_health``. The supervisor sees the parent exit, marks the
program as "completed," and respawns it — but the original server child is
still holding port 8787 in a detached process group. The new bootstrap fails
to bind, exits non-zero, supervisor respawns again, loops until something
crashes the orphan and frees the port.
The fix
-------
Add ``--foreground`` flag and supervisor-environment auto-detection. In
foreground mode we replace the current process via ``os.execv`` with the
server, so the supervisor sees the long-lived server as the original child.
The legacy ``Popen + wait_for_health`` path is preserved for interactive
``bash start.sh`` runs.
Coverage
--------
1. ``--foreground`` is a recognized argparse flag
2. ``_detect_supervisor()`` returns None on a clean env
3. ``_detect_supervisor()`` returns the env-var name on each known supervisor
(``INVOCATION_ID`` / ``JOURNAL_STREAM`` / ``NOTIFY_SOCKET`` /
``XPC_SERVICE_NAME`` / ``SUPERVISOR_ENABLED``)
4. ``_detect_supervisor()`` returns ``HERMES_WEBUI_FOREGROUND`` for the
explicit opt-in, accepting ``1``/``true``/``yes``/``on`` (case-insensitive)
5. ``_detect_supervisor()`` ignores ``HERMES_WEBUI_FOREGROUND=0`` /
``=false`` / ``=`` and falls through to env-var probing
6. ``XPC_SERVICE_NAME`` noise filter: bare ``"0"`` and ``application.<id>``
values do NOT trigger foreground (the macOS Terminal default state),
while real launchd Labels (``com.<rdns>.<svc>``) do
7. ``main()`` calls ``os.execv`` (NOT ``subprocess.Popen``) when
``--foreground`` is passed
8. ``main()`` calls ``os.execv`` (NOT ``subprocess.Popen``) when a supervisor
env var is set even without the explicit flag
9. Default ``main()`` path (no flag, clean env) still uses ``Popen``
10. Foreground path chdir's to ``agent_dir or REPO_ROOT`` before execv (matches
the cwd the legacy Popen uses)
11. Foreground path exports ``HERMES_WEBUI_HOST`` / ``HERMES_WEBUI_PORT`` /
``HERMES_WEBUI_AGENT_DIR`` / ``HERMES_WEBUI_STATE_DIR`` to ``os.environ``
so the post-exec server picks them up
12. Foreground path skips ``wait_for_health`` (no client to retry from)
13. ``--foreground`` help text mentions launchd / systemd / supervisord
14. Non-executable ``python_exe`` raises ``RuntimeError`` instead of
looping the supervisor on ``execv`` failure
These tests do NOT actually exec — ``os.execv`` is monkeypatched. We're
pinning the structural choice (which path runs, which cwd, which env) not the
post-exec behavior (which is the OS kernel's job).
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
REPO_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(REPO_ROOT))
# ---------- helpers --------------------------------------------------------
@pytest.fixture
def clean_env(monkeypatch):
"""Strip all known supervisor env vars + resolved bootstrap vars so each
test starts from a known-clean state.
The resolved-vars stripping (HERMES_WEBUI_HOST etc.) prevents leakage
where a previous test's ``main()`` mutated ``os.environ`` and a later
test re-imports ``bootstrap``, picking up the polluted defaults. With
these stripped, ``DEFAULT_HOST`` / ``DEFAULT_PORT`` fall back to their
hardcoded defaults at module load time.
"""
for name in (
# Supervisor-detection env vars
"INVOCATION_ID",
"JOURNAL_STREAM",
"NOTIFY_SOCKET",
"XPC_SERVICE_NAME",
"SUPERVISOR_ENABLED",
"HERMES_WEBUI_FOREGROUND",
# Bootstrap-resolved env vars (mutated by main(), can leak across tests)
"HERMES_WEBUI_HOST",
"HERMES_WEBUI_PORT",
"HERMES_WEBUI_AGENT_DIR",
):
monkeypatch.delenv(name, raising=False)
@pytest.fixture
def import_bootstrap():
"""Import bootstrap freshly each test to avoid module-level state bleed."""
# bootstrap.py runs ``_load_repo_dotenv()`` at import time; that's idempotent.
if "bootstrap" in sys.modules:
del sys.modules["bootstrap"]
import bootstrap as bs
return bs
# ---------- argparse coverage ---------------------------------------------
class TestForegroundFlag:
def test_foreground_is_recognized_flag(self, import_bootstrap, monkeypatch):
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
args = import_bootstrap.parse_args()
assert args.foreground is True
assert args.port == import_bootstrap.DEFAULT_PORT # default preserved
assert args.host == import_bootstrap.DEFAULT_HOST
def test_foreground_default_is_false(self, import_bootstrap, monkeypatch):
monkeypatch.setattr(sys, "argv", ["bootstrap.py"])
args = import_bootstrap.parse_args()
assert args.foreground is False
def test_foreground_help_mentions_supervisors(self, import_bootstrap, monkeypatch, capsys):
# argparse prints help and exits — capture and verify content.
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--help"])
with pytest.raises(SystemExit):
import_bootstrap.parse_args()
out = capsys.readouterr().out
assert "--foreground" in out
assert "launchd" in out
assert "systemd" in out
assert "supervisord" in out
# ---------- _detect_supervisor() ------------------------------------------
class TestDetectSupervisor:
def test_clean_env_returns_none(self, import_bootstrap, clean_env):
assert import_bootstrap._detect_supervisor() is None
@pytest.mark.parametrize("var", [
"INVOCATION_ID",
"JOURNAL_STREAM",
"NOTIFY_SOCKET",
"XPC_SERVICE_NAME",
"SUPERVISOR_ENABLED",
])
def test_each_supervisor_var_triggers(self, import_bootstrap, clean_env, monkeypatch, var):
monkeypatch.setenv(var, "anything-truthy")
assert import_bootstrap._detect_supervisor() == var
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "Yes", "on", "ON"])
def test_explicit_opt_in_truthy_values(self, import_bootstrap, clean_env, monkeypatch, value):
monkeypatch.setenv("HERMES_WEBUI_FOREGROUND", value)
assert import_bootstrap._detect_supervisor() == "HERMES_WEBUI_FOREGROUND"
@pytest.mark.parametrize("value", ["0", "false", "FALSE", "no", "off", "", " "])
def test_explicit_opt_in_falsy_values_fall_through(self, import_bootstrap, clean_env, monkeypatch, value):
# When HERMES_WEBUI_FOREGROUND is falsy, we should NOT short-circuit on it.
# If no other supervisor var is set, returns None.
monkeypatch.setenv("HERMES_WEBUI_FOREGROUND", value)
assert import_bootstrap._detect_supervisor() is None
def test_explicit_opt_in_takes_precedence_over_supervisor_var(self, import_bootstrap, clean_env, monkeypatch):
# Both set → explicit flag wins (returned name reflects user intent).
monkeypatch.setenv("HERMES_WEBUI_FOREGROUND", "1")
monkeypatch.setenv("INVOCATION_ID", "deadbeef")
assert import_bootstrap._detect_supervisor() == "HERMES_WEBUI_FOREGROUND"
class TestXPCServiceNameNoiseFilter:
"""macOS launchd sets XPC_SERVICE_NAME in EVERY Terminal-spawned shell.
Without filtering, every Mac dev running ``./start.sh`` would silently
auto-promote to foreground mode and lose the /health probe + browser open
+ bootstrap log. We narrow to launchd Label-style names (com.<rdns>.<svc>)
while rejecting the well-known noise values.
"""
@pytest.mark.parametrize("noise_value", [
"0", # launchd descendants
"application.com.apple.Terminal.0BCDDEAD-1234-5678", # Terminal.app shells
"application.com.googlecode.iterm2", # iTerm2
"application.com.microsoft.VSCode", # VSCode terminal
])
def test_xpc_noise_values_do_not_trigger(self, import_bootstrap, clean_env, monkeypatch, noise_value):
monkeypatch.setenv("XPC_SERVICE_NAME", noise_value)
assert import_bootstrap._detect_supervisor() is None, (
f"XPC_SERVICE_NAME={noise_value!r} should not trigger foreground "
f"mode — that would break interactive ./start.sh on every Mac."
)
@pytest.mark.parametrize("real_value", [
"com.example.hermes-webui",
"com.acme.production-server",
"io.github.user.my-service",
])
def test_xpc_real_label_triggers(self, import_bootstrap, clean_env, monkeypatch, real_value):
monkeypatch.setenv("XPC_SERVICE_NAME", real_value)
assert import_bootstrap._detect_supervisor() == "XPC_SERVICE_NAME", (
f"XPC_SERVICE_NAME={real_value!r} is a launchd Label and should "
f"trigger foreground mode."
)
def test_xpc_noise_does_not_block_other_supervisor_var(self, import_bootstrap, clean_env, monkeypatch):
# If XPC has a noise value but INVOCATION_ID is set (mixed env, e.g.
# systemd unit run on a Mac CI runner), we should still detect via
# INVOCATION_ID rather than swallow it.
monkeypatch.setenv("XPC_SERVICE_NAME", "0")
monkeypatch.setenv("INVOCATION_ID", "deadbeef")
assert import_bootstrap._detect_supervisor() == "INVOCATION_ID"
# ---------- main() routing ------------------------------------------------
class TestMainForegroundRouting:
"""Verify which code path main() takes under each input combination.
These are STRUCTURAL tests — they pin which call (execv vs Popen) is made,
not the result. We monkeypatch every external side effect so main() runs
in a hermetic environment.
"""
@pytest.fixture
def stub_main_dependencies(self, monkeypatch, tmp_path):
"""Stub out everything main() calls except the routing decision."""
import bootstrap as bs
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
monkeypatch.setattr(bs, "discover_agent_dir", lambda: tmp_path / "agent")
monkeypatch.setattr(bs, "hermes_command_exists", lambda: True)
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: "/usr/bin/python3")
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda p: p)
monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: True)
monkeypatch.setattr(bs, "open_browser", lambda *a, **kw: None)
monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(tmp_path / "state"))
# Make agent_dir exist so chdir doesn't fail.
(tmp_path / "agent").mkdir(parents=True, exist_ok=True)
return bs
def test_default_path_uses_popen(self, stub_main_dependencies, clean_env, monkeypatch):
bs = stub_main_dependencies
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--no-browser"])
execv_calls = []
popen_calls = []
monkeypatch.setattr(os, "execv", lambda *a: execv_calls.append(a))
class FakePopen:
pid = 12345
def __init__(self, *args, **kwargs):
popen_calls.append((args, kwargs))
monkeypatch.setattr(subprocess, "Popen", FakePopen)
rc = bs.main()
assert rc == 0
assert len(popen_calls) == 1, "Default path should call subprocess.Popen exactly once"
assert len(execv_calls) == 0, "Default path must NOT call os.execv"
def test_foreground_flag_uses_execv(self, stub_main_dependencies, clean_env, monkeypatch):
bs = stub_main_dependencies
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
execv_calls = []
popen_calls = []
# execv normally replaces the process; we capture+raise SystemExit so
# main() returns control to us instead of falling through to the
# legacy Popen branch.
def fake_execv(path, argv):
execv_calls.append((path, argv))
raise SystemExit(0)
monkeypatch.setattr(os, "execv", fake_execv)
monkeypatch.setattr(os, "chdir", lambda p: None)
def fake_popen(*args, **kwargs):
popen_calls.append((args, kwargs))
return None
monkeypatch.setattr(subprocess, "Popen", fake_popen)
with pytest.raises(SystemExit) as ei:
bs.main()
assert ei.value.code == 0
assert len(execv_calls) == 1, "--foreground must call os.execv exactly once"
assert len(popen_calls) == 0, "--foreground must NOT call subprocess.Popen"
path, argv = execv_calls[0]
assert path == "/usr/bin/python3"
# argv[0] is the program name (convention), argv[1] is the script
assert argv[0] == "/usr/bin/python3"
assert argv[1].endswith("server.py")
@pytest.mark.parametrize("var", [
"INVOCATION_ID",
"JOURNAL_STREAM",
"NOTIFY_SOCKET",
"XPC_SERVICE_NAME",
"SUPERVISOR_ENABLED",
])
def test_supervisor_env_var_auto_promotes_to_execv(self, stub_main_dependencies, clean_env, monkeypatch, var):
bs = stub_main_dependencies
monkeypatch.setattr(sys, "argv", ["bootstrap.py"]) # no --foreground
monkeypatch.setenv(var, "deadbeef")
execv_calls = []
popen_calls = []
def fake_execv(path, argv):
execv_calls.append((path, argv))
raise SystemExit(0)
monkeypatch.setattr(os, "execv", fake_execv)
monkeypatch.setattr(os, "chdir", lambda p: None)
def fake_popen(*args, **kwargs):
popen_calls.append((args, kwargs))
return None
monkeypatch.setattr(subprocess, "Popen", fake_popen)
with pytest.raises(SystemExit):
bs.main()
assert len(execv_calls) == 1, f"{var} must auto-promote to execv"
assert len(popen_calls) == 0, f"{var} must NOT use Popen"
def test_explicit_opt_in_env_auto_promotes_to_execv(self, stub_main_dependencies, clean_env, monkeypatch):
bs = stub_main_dependencies
monkeypatch.setattr(sys, "argv", ["bootstrap.py"]) # no --foreground flag
monkeypatch.setenv("HERMES_WEBUI_FOREGROUND", "1")
execv_calls = []
def fake_execv(path, argv):
execv_calls.append((path, argv))
raise SystemExit(0)
monkeypatch.setattr(os, "execv", fake_execv)
monkeypatch.setattr(os, "chdir", lambda p: None)
monkeypatch.setattr(subprocess, "Popen", lambda *a, **kw: None)
with pytest.raises(SystemExit):
bs.main()
assert len(execv_calls) == 1
class TestForegroundEnvAndCwd:
"""The post-execv server.py inherits os.environ and cwd from us."""
@pytest.fixture
def setup(self, monkeypatch, tmp_path):
import bootstrap as bs
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
agent_dir = tmp_path / "agent"
agent_dir.mkdir()
monkeypatch.setattr(bs, "discover_agent_dir", lambda: agent_dir)
monkeypatch.setattr(bs, "hermes_command_exists", lambda: True)
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: "/usr/bin/python3")
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda p: p)
monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: True)
monkeypatch.setattr(bs, "open_browser", lambda *a, **kw: None)
# State-dir + every var we care about is captured.
monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(tmp_path / "state"))
return bs, agent_dir
def test_foreground_chdirs_to_agent_dir_before_exec(self, setup, monkeypatch, clean_env):
bs, agent_dir = setup
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground", "--host", "127.0.0.1", "9999"])
chdir_calls = []
monkeypatch.setattr(os, "chdir", lambda p: chdir_calls.append(p))
def fake_execv(*a):
raise SystemExit(0)
monkeypatch.setattr(os, "execv", fake_execv)
with pytest.raises(SystemExit):
bs.main()
assert len(chdir_calls) == 1
assert chdir_calls[0] == str(agent_dir)
def test_foreground_exports_resolved_env_vars(self, setup, monkeypatch, clean_env):
bs, agent_dir = setup
monkeypatch.setattr(sys, "argv", [
"bootstrap.py", "--foreground", "--host", "0.0.0.0", "9119"
])
monkeypatch.setattr(os, "chdir", lambda p: None)
def fake_execv(*a):
raise SystemExit(0)
monkeypatch.setattr(os, "execv", fake_execv)
with pytest.raises(SystemExit):
bs.main()
# Post-execv server.py inherits these — verify we set them on os.environ
# (not just a local copy).
assert os.environ["HERMES_WEBUI_HOST"] == "0.0.0.0"
assert os.environ["HERMES_WEBUI_PORT"] == "9119"
assert os.environ["HERMES_WEBUI_AGENT_DIR"] == str(agent_dir)
# state-dir was already set by the fixture; verify it survived.
assert "HERMES_WEBUI_STATE_DIR" in os.environ
def test_foreground_does_not_call_wait_for_health(self, setup, monkeypatch, clean_env):
bs, _ = setup
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
monkeypatch.setattr(os, "chdir", lambda p: None)
wait_calls = []
monkeypatch.setattr(bs, "wait_for_health", lambda *a, **kw: (wait_calls.append(a), True)[1])
def fake_execv(*a):
raise SystemExit(0)
monkeypatch.setattr(os, "execv", fake_execv)
with pytest.raises(SystemExit):
bs.main()
# In foreground mode there's no parent left to retry from — the
# supervisor's KeepAlive handles it. wait_for_health must not run.
assert len(wait_calls) == 0
class TestForegroundExecutabilityGuard:
"""If python_exe is missing or non-executable, raise a clear error
instead of letting os.execv raise OSError → SystemExit(1) → supervisor
restart loop. This guard prevents the exact failure mode #1458 reports."""
@pytest.fixture
def setup_with_bad_python(self, monkeypatch, tmp_path):
import bootstrap as bs
agent_dir = tmp_path / "agent"
agent_dir.mkdir()
# Create a non-executable file at the python path
bad_python = tmp_path / "bad-python"
bad_python.write_text("#!/bin/bash\necho hi", encoding="utf-8")
bad_python.chmod(0o644) # NOT executable
monkeypatch.setattr(bs, "ensure_supported_platform", lambda: None)
monkeypatch.setattr(bs, "discover_agent_dir", lambda: agent_dir)
monkeypatch.setattr(bs, "hermes_command_exists", lambda: True)
monkeypatch.setattr(bs, "discover_launcher_python", lambda *a: str(bad_python))
monkeypatch.setattr(bs, "ensure_python_has_webui_deps", lambda p: p)
monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(tmp_path / "state"))
return bs
def test_non_executable_python_raises_runtime_error(self, setup_with_bad_python, monkeypatch, clean_env):
bs = setup_with_bad_python
monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"])
execv_calls = []
monkeypatch.setattr(os, "execv", lambda *a: execv_calls.append(a))
monkeypatch.setattr(os, "chdir", lambda p: None)
with pytest.raises(RuntimeError, match="not executable"):
bs.main()
# execv must NOT have been called when the guard fires
assert len(execv_calls) == 0

View file

@ -47,9 +47,11 @@ def _make_session(session_id, stream_id=None, message_count=1):
def test_all_sessions_marks_indexed_and_in_memory_streaming_sessions():
"""Session records from both index and in-memory cache should expose is_streaming."""
s_disk = _make_session("disk_session", stream_id="stream-1")
s_disk.pending_user_message = "pending disk turn"
s_disk.save()
s_memory = _make_session("memory_session", stream_id="stream-2")
s_memory.pending_user_message = "pending memory turn"
with models.LOCK:
models.SESSIONS[s_memory.session_id] = s_memory
@ -62,6 +64,8 @@ def test_all_sessions_marks_indexed_and_in_memory_streaming_sessions():
assert by_sid["disk_session"]["is_streaming"] is True
assert by_sid["memory_session"]["is_streaming"] is True
assert by_sid["memory_session"]["active_stream_id"] == "stream-2"
assert by_sid["disk_session"]["pending_user_message"] == "pending disk turn"
assert by_sid["memory_session"]["pending_user_message"] == "pending memory turn"
def test_all_sessions_marks_streaming_false_when_stream_is_not_active():