Reload stale OpenCode frames after server recovery (#1860)

## Summary

- monitor the proxied OpenCode health endpoint from the stable
ArchiveBox parent page
- reload the iframe once after an unavailable -> healthy transition
- trigger an immediate check again when the browser comes online or the
tab becomes visible

## Why

A real RC416 -> RC417 DigestBox restart left the already-open OpenCode
1.17.14 SPA showing its fatal error page against the newly started
OpenCode 1.17.15 server. A full page reload recovered immediately. The
parent ArchiveBox document survives the container restart, so it can
perform that one iframe reload automatically after the server is healthy
again.

## Verification

- `test_opencode_agent_superuser_gets_admin_wrapper` passes with
assertions for the recovery monitor
- focused pre-commit hooks pass
- real stale-tab restart reproduction will be repeated on DigestBox
after release

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Reloads stale OpenCode iframes after the ArchiveBox server recovers from
a restart, so the previously fatal error page is replaced automatically.
The parent page polls the OpenCode health endpoint every 3 seconds,
reloading the iframe once when the server transitions from unavailable
to healthy or when the server version changes, and also re-checks
immediately when the browser comes online or the tab becomes visible.

- Health monitoring uses a separate endpoint that reports status without
starting the server; recovery restarts a running but unhealthy OpenCode
process, and polling skips while hidden when the server is down.

**Verification**
- `test_opencode_agent_superuser_gets_admin_wrapper` passes with
assertions for the recovery monitor.
- Focused pre-commit hooks pass.

<sup>Written for commit a33b333b6a.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1860?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Nick Sweeting 2026-09-02 23:23:56 -07:00 committed by GitHub
commit a71ef00b79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 155 additions and 2 deletions

View File

@ -206,6 +206,78 @@
});
})();
</script>
<script>
(() => {
const frame = document.querySelector(".opencode-agent-frame");
if (!frame) return;
const healthUrl = "{{ proxy_prefix|escapejs }}/global/health";
const monitorUrl = "{{ proxy_prefix|escapejs }}/_archivebox/health";
const fetchOptions = {
cache: "no-store",
credentials: "same-origin",
redirect: "manual",
};
let checking = false;
let waking = false;
let wasUnavailable = false;
let healthyChecks = 0;
let knownVersion = "{{ opencode_version|escapejs }}";
const wake = () => {
if (waking) return;
waking = true;
fetch(healthUrl, fetchOptions)
.catch(() => {})
.finally(() => { waking = false; });
};
const check = async () => {
if (checking || (document.hidden && wasUnavailable)) return;
checking = true;
try {
const response = await fetch(monitorUrl, fetchOptions);
if (!response.ok || response.redirected) {
wasUnavailable = true;
healthyChecks = 0;
if (response.status === 503) {
wake();
}
return;
}
const health = await response.json();
if (!health || health.healthy !== true) {
wasUnavailable = true;
healthyChecks = 0;
return;
}
const currentVersion = typeof health.version === "string" ? health.version : "";
if (knownVersion && currentVersion && currentVersion !== knownVersion) {
wasUnavailable = true;
}
if (currentVersion) {
knownVersion = currentVersion;
}
if (wasUnavailable && ++healthyChecks >= 2) {
wasUnavailable = false;
healthyChecks = 0;
frame.contentWindow.location.reload();
}
} catch {
wasUnavailable = true;
healthyChecks = 0;
} finally {
checking = false;
}
};
window.setInterval(check, 3000);
window.addEventListener("online", check);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) check();
});
check();
})();
</script>
{% endif %}
</div>
{% endblock %}

View File

@ -1,10 +1,11 @@
from django.urls import path, re_path
from archivebox.opencode.views import agent_view, opencode_proxy_view
from archivebox.opencode.views import agent_health_view, agent_view, opencode_proxy_view
urlpatterns = [
path("", agent_view, name="opencode-agent"),
path("opencode/_archivebox/health", agent_health_view, name="opencode-health"),
re_path(
r"^opencode(?:/(?P<path>.*))?$",
opencode_proxy_view,

View File

@ -26,6 +26,7 @@ from django.http import (
HttpRequest,
HttpResponse,
HttpResponseForbidden,
JsonResponse,
StreamingHttpResponse,
)
from django.shortcuts import redirect, render
@ -378,14 +379,29 @@ def _health(settings: dict) -> bool:
return False
def _opencode_version(settings: dict) -> str:
try:
response = requests.get(
f"{settings['origin']}/global/health",
timeout=2,
)
response.raise_for_status()
data = response.json()
return str(data.get("version") or "") if isinstance(data, dict) else ""
except (requests.RequestException, ValueError):
return ""
def _ensure_opencode(settings: dict) -> tuple[bool, str]:
global _PROCESS
started_process: subprocess.Popen | None = None
workdir = settings["workdir"].resolve()
with _PROCESS_LOCK:
if (_PROCESS is not None and _PROCESS.poll() is None) or _health(settings):
if _health(settings):
return True, ""
if _PROCESS is not None and _PROCESS.poll() is None:
_stop_owned_process(_PROCESS)
try:
binary, git_binary, binary_env = _resolve_binary(
@ -494,10 +510,15 @@ def agent_view(request: HttpRequest):
settings["archivebox_api_url"] = api_url
ok, error = _ensure_opencode(settings)
recent_session_id = ""
opencode_version = ""
if ok:
try:
with _SESSION_LOCK:
recent_session_id = _ensure_default_session(settings)
opencode_version = _opencode_version(settings)
if not opencode_version:
ok = False
error = "OpenCode health check did not report its version."
except (requests.RequestException, RuntimeError, ValueError) as err:
ok = False
error = f"OpenCode project initialization failed: {err}"
@ -514,6 +535,8 @@ def agent_view(request: HttpRequest):
# open the durable session URL so a fresh browser does not land on the
# transient new-session route and appear to have lost prior sessions.
"proxy_url": _project_route(settings["workdir"], recent_session_id),
"proxy_prefix": _PROXY_PREFIX,
"opencode_version": opencode_version,
"workdir": str(settings["workdir"].resolve()),
"recent_session_id": recent_session_id,
}
@ -525,6 +548,23 @@ def agent_view(request: HttpRequest):
)
def agent_health_view(request: HttpRequest):
config = _machine_config()
_require_enabled(config)
auth_response = _require_superuser(request)
if auth_response:
return auth_response
version = _opencode_version(_settings(config))
healthy = bool(version)
response = JsonResponse(
{"healthy": healthy, "version": version},
status=200 if healthy else 503,
)
response.headers["Cache-Control"] = "no-store"
return response
def _proxy_url(settings: dict, path: str | None) -> str:
rel = "/" if not path else f"/{path}"
return urljoin(settings["origin"], rel)

View File

@ -186,6 +186,15 @@ def test_opencode_agent_superuser_gets_admin_wrapper(admin_client, live_opencode
assert f'<iframe src="{session_path}"'.encode() in response.content
assert b'id="header"' in response.content
assert b'id="progress-monitor"' in response.content
assert response.context["proxy_prefix"] == views._PROXY_PREFIX
assert response.context["opencode_version"]
assert b"const healthUrl" in response.content
assert b"/admin/agent/opencode/global/health" in response.content
assert b"/admin/agent/opencode/_archivebox/health" in response.content
assert b'redirect: "manual"' in response.content
assert b"let waking = false" in response.content
assert b"wake();" in response.content
assert b"frame.contentWindow.location.reload()" in response.content
assert response.headers["X-Frame-Options"] == "DENY"
assert response.headers["Content-Security-Policy"] == "frame-ancestors 'none'"
@ -199,6 +208,21 @@ def test_opencode_agent_superuser_gets_admin_wrapper(admin_client, live_opencode
assert session.headers["Content-Security-Policy"] == "frame-ancestors 'self'"
def test_opencode_health_monitor_does_not_start_server(admin_client, live_opencode):
from archivebox.opencode import views
views._stop_owned_process()
response = admin_client.get(
"/admin/agent/opencode/_archivebox/health",
HTTP_HOST=ADMIN_TEST_HOST,
)
assert response.status_code == 503
assert response.json() == {"healthy": False, "version": ""}
assert response.headers["Cache-Control"] == "no-store"
assert views._PROCESS is None
def test_opencode_proxy_serves_real_project_and_session(admin_client, live_opencode):
workdir = str(live_opencode.config.data_dir.resolve())
encoded_workdir = quote(workdir)
@ -261,6 +285,22 @@ def test_concurrent_opencode_startup_waits_until_server_is_ready(live_opencode):
assert views._health(live_opencode.settings)
def test_opencode_restarts_an_unhealthy_owned_process(live_opencode):
from archivebox.opencode import views
old_process = views._PROCESS
settings = {**live_opencode.settings, "port": _free_port()}
settings["origin"] = f"http://{settings['host']}:{settings['port']}"
ok, error = views._ensure_opencode(settings)
assert ok, error
assert old_process is not None
assert old_process.poll() is not None
assert views._PROCESS is not old_process
assert views._health(settings)
def test_opencode_proxy_sse_response_is_unbuffered(admin_client, live_opencode):
response = admin_client.get(
"/admin/agent/opencode/global/event",