fix(console): stop git-info 404s, profile-mismatch 409 poll loops and the wasted pwa-startup preload
- /api/git-info falls back to state.db metadata for sessions without a WebUI sidecar (CLI, delegated subagents), mirroring /api/list, instead of 404ing. - Approval and clarify fallback pollers stop on session_profile_mismatch 409 instead of re-requesting every 1.5s/3s; live cards stay standing. - Drop <link rel=preload> for pwa-startup.js: the preload scanner resolves it before the dynamic <base href> is written, so on /session/<id> it fetches /session/static/... while the script loads /static/..., a wasted duplicate request plus a 'preloaded but not used' warning on every load.
This commit is contained in:
1 parent
b8bda9a99e
commit
c713c6efc2
5 files changed
+237
-8
No files matched your search
+12
-3
@@ -14572,13 +14572,22 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
if not sid:
|
if not sid:
|
||||||
return bad(handler, "session_id required")
|
return bad(handler, "session_id required")
|
||||||
try:
|
try:
|
||||||
s = get_session(sid)
|
workspace = get_session(sid).workspace
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return bad(handler, "Session not found", 404)
|
# state.db-only sessions (CLI, delegated subagents): same fallback as /api/list.
|
||||||
|
cli_meta = _lookup_cli_session_metadata(sid)
|
||||||
|
if not cli_meta:
|
||||||
|
return bad(handler, "Session not found", 404)
|
||||||
|
if not cli_meta.get("workspace"):
|
||||||
|
return j(handler, {"git": None})
|
||||||
|
try:
|
||||||
|
workspace = resolve_trusted_workspace(cli_meta["workspace"])
|
||||||
|
except (FileNotFoundError, ValueError):
|
||||||
|
return j(handler, {"git": None})
|
||||||
from api.workspace_git import GitWorkspaceError, git_status
|
from api.workspace_git import GitWorkspaceError, git_status
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status = git_status(Path(s.workspace))
|
status = git_status(Path(workspace))
|
||||||
except GitWorkspaceError as e:
|
except GitWorkspaceError as e:
|
||||||
return _git_bad(handler, e)
|
return _git_bad(handler, e)
|
||||||
totals = status.get("totals") or {}
|
totals = status.get("totals") or {}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
<script>(function(){try{var t=localStorage.getItem('hermes-theme')||'dark';if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';var c=t==='dark'?'#141425':'#FAF7F0';document.querySelectorAll('meta[name="theme-color"]').forEach(function(m){m.setAttribute('content',c);m.removeAttribute('media');});}catch(e){}})()</script>
|
<script>(function(){try{var t=localStorage.getItem('hermes-theme')||'dark';if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';var c=t==='dark'?'#141425':'#FAF7F0';document.querySelectorAll('meta[name="theme-color"]').forEach(function(m){m.setAttribute('content',c);m.removeAttribute('media');});}catch(e){}})()</script>
|
||||||
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
|
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
|
||||||
<script>(function(){try{if(localStorage.getItem('hermes-webui-sidebar-collapsed')==='1')document.documentElement.dataset.sidebarCollapsed='1';}catch(e){}})()</script>
|
<script>(function(){try{if(localStorage.getItem('hermes-webui-sidebar-collapsed')==='1')document.documentElement.dataset.sidebarCollapsed='1';}catch(e){}})()</script>
|
||||||
<link rel="preload" href="static/pwa-startup.js?v=__WEBUI_VERSION__" as="script">
|
|
||||||
<script src="static/pwa-startup.js?v=__WEBUI_VERSION__"></script>
|
<script src="static/pwa-startup.js?v=__WEBUI_VERSION__"></script>
|
||||||
<script>window.__HERMES_CONFIG__={maxUploadBytes:__MAX_UPLOAD_BYTES__,csrfToken:__CSRF_TOKEN_JSON__};</script>
|
<script>window.__HERMES_CONFIG__={maxUploadBytes:__MAX_UPLOAD_BYTES__,csrfToken:__CSRF_TOKEN_JSON__};</script>
|
||||||
<script>window.__HERMES_WEBUI_BUNDLE_VERSION__='__WEBUI_VERSION__';</script>
|
<script>window.__HERMES_WEBUI_BUNDLE_VERSION__='__WEBUI_VERSION__';</script>
|
||||||
|
|||||||
+13
-1
@@ -8060,7 +8060,13 @@ function _startApprovalFallbackPoll(sid) {
|
|||||||
stopApprovalPollingForSession(sid);
|
stopApprovalPollingForSession(sid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(e) { /* ignore poll errors */ }
|
} catch(e) {
|
||||||
|
// Another profile owns this session now (e.g. a different tab switched the shared
|
||||||
|
// profile cookie): every further poll would 409, so stop and leave the card as-is.
|
||||||
|
if (typeof _sessionProfileMismatchFromError === 'function' && _sessionProfileMismatchFromError(e)) {
|
||||||
|
stopApprovalPollingForSession(sid);
|
||||||
|
}
|
||||||
|
}
|
||||||
finally { _approvalFallbackPollInFlight = false; }
|
finally { _approvalFallbackPollInFlight = false; }
|
||||||
};
|
};
|
||||||
_approvalPollTimer = setInterval(_tick, 1500); // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness
|
_approvalPollTimer = setInterval(_tick, 1500); // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness
|
||||||
@@ -9204,6 +9210,12 @@ function _startClarifyFallbackPoll(sid) {
|
|||||||
currentSessionId: currentSid,
|
currentSessionId: currentSid,
|
||||||
message: msg,
|
message: msg,
|
||||||
};
|
};
|
||||||
|
// Profile-mismatch 409: the session is owned by another profile under the current
|
||||||
|
// cookie, so polling can never succeed. Stop quietly; the live card stays standing.
|
||||||
|
if (typeof _sessionProfileMismatchFromError === "function" && _sessionProfileMismatchFromError(e)) {
|
||||||
|
stopClarifyPollingForSession(sid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// A 404 from the active session domain is a STALE-SESSION signal — e.g.
|
// A 404 from the active session domain is a STALE-SESSION signal — e.g.
|
||||||
// the old profile's session still polling briefly after a profile switch,
|
// the old profile's session still polling briefly after a profile switch,
|
||||||
// or a session deleted server-side — NOT a missing clarify endpoint. Stop
|
// or a session deleted server-side — NOT a missing clarify endpoint. Stop
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Console-noise regressions: git-info 404 on state.db-only sessions, pollers
|
||||||
|
that keep 409ing after a profile-cookie flip, and the unused pwa-startup preload."""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import api.routes as routes
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
||||||
|
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||||
|
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class _Handler:
|
||||||
|
def __init__(self):
|
||||||
|
self.wfile = io.BytesIO()
|
||||||
|
self.headers = {}
|
||||||
|
self.status = None
|
||||||
|
|
||||||
|
def send_response(self, status):
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
def send_header(self, *_a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def payload(self):
|
||||||
|
return json.loads(self.wfile.getvalue().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _git_info(sid):
|
||||||
|
h = _Handler()
|
||||||
|
routes.handle_get(h, urlparse(f"/api/git-info?session_id={sid}"))
|
||||||
|
return h.status, h.payload()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def git_repo(tmp_path):
|
||||||
|
if not shutil.which("git"):
|
||||||
|
pytest.skip("git not installed")
|
||||||
|
run = lambda *a: subprocess.run(["git", "-C", str(tmp_path), *a], check=True, capture_output=True)
|
||||||
|
run("init", "-q", "-b", "main")
|
||||||
|
(tmp_path / "a.txt").write_text("a")
|
||||||
|
run("add", "a.txt")
|
||||||
|
run("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "init")
|
||||||
|
(tmp_path / "b.txt").write_text("b")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _state_db_only(monkeypatch, sid, row):
|
||||||
|
def _missing(*_a, **_kw):
|
||||||
|
raise KeyError(sid)
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes, "get_session", _missing)
|
||||||
|
monkeypatch.setattr(routes, "get_cli_sessions", lambda *_a, **_kw: [row] if row else [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_info_serves_state_db_only_session(monkeypatch, git_repo):
|
||||||
|
sid = "20260923_175914_e47592"
|
||||||
|
_state_db_only(monkeypatch, sid, {"session_id": sid, "workspace": str(git_repo)})
|
||||||
|
monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda p, *_a, **_kw: Path(p))
|
||||||
|
|
||||||
|
status, body = _git_info(sid)
|
||||||
|
|
||||||
|
assert status == 200, body
|
||||||
|
assert body["git"]["is_git"] is True
|
||||||
|
assert body["git"]["branch"] == "main"
|
||||||
|
assert body["git"]["untracked"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_info_untrusted_state_db_workspace_is_not_inspected(monkeypatch, git_repo):
|
||||||
|
sid = "20260923_175914_e9d4bd"
|
||||||
|
_state_db_only(monkeypatch, sid, {"session_id": sid, "workspace": str(git_repo)})
|
||||||
|
|
||||||
|
def _untrusted(*_a, **_kw):
|
||||||
|
raise ValueError("outside trusted roots")
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes, "resolve_trusted_workspace", _untrusted)
|
||||||
|
assert _git_info(sid) == (200, {"git": None})
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_info_state_db_session_without_workspace_has_no_badge(monkeypatch):
|
||||||
|
sid = "20260923_000000_abcdef"
|
||||||
|
_state_db_only(monkeypatch, sid, {"session_id": sid, "workspace": ""})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
routes, "resolve_trusted_workspace",
|
||||||
|
lambda *_a, **_kw: pytest.fail("must not fall back to the default workspace"),
|
||||||
|
)
|
||||||
|
assert _git_info(sid) == (200, {"git": None})
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_info_unknown_session_still_404s(monkeypatch):
|
||||||
|
_state_db_only(monkeypatch, "nope", None)
|
||||||
|
status, body = _git_info("nope")
|
||||||
|
assert status == 404 and body["error"] == "Session not found"
|
||||||
|
|
||||||
|
|
||||||
|
# ── frontend pollers, driven through node with the real functions ─────────────
|
||||||
|
|
||||||
|
def _extract_fn(src, name):
|
||||||
|
start = src.index(f"function {name}(")
|
||||||
|
brace = src.index("{", src.index(")", start))
|
||||||
|
depth, i = 1, brace + 1
|
||||||
|
while depth:
|
||||||
|
depth += {"{": 1, "}": -1}.get(src[i], 0)
|
||||||
|
i += 1
|
||||||
|
return src[start:i]
|
||||||
|
|
||||||
|
|
||||||
|
_HARNESS = r"""
|
||||||
|
var S = {session: {session_id: 'sid1'}, busy: false};
|
||||||
|
var calls = {api: 0, warn: 0, hide: 0};
|
||||||
|
var _approvalPollTimer = null, _approvalEventSource = null, _approvalSSEHealthTimer = null;
|
||||||
|
var _approvalFallbackPollInFlight = false, _approvalPollingSessionId = 'sid1';
|
||||||
|
var _clarifyEventSource = null, _clarifyFallbackTimer = null, _clarifyHealthTimer = null;
|
||||||
|
var _clarifyFallbackPollInFlight = false, _clarifyPollingSessionId = null, _clarifyMissingEndpointWarned = false;
|
||||||
|
var _approvalPendingBySession = new Map();
|
||||||
|
function _approvalPollingSessionMissingOrMismatched(sid){ return !sid || !S.session || S.session.session_id !== sid; }
|
||||||
|
function _hideApprovalCardIfOwner(){ calls.hide++; }
|
||||||
|
function _hideClarifyCardIfOwner(){ calls.hide++; }
|
||||||
|
function _clearApprovalPendingForSession(){}
|
||||||
|
function _clearClarifyPendingForSession(){}
|
||||||
|
function showApprovalForSession(){}
|
||||||
|
function showClarifyForSession(){}
|
||||||
|
function setComposerStatus(){}
|
||||||
|
function showToast(){}
|
||||||
|
console.warn = function(){ calls.warn++; };
|
||||||
|
var timers = [];
|
||||||
|
setInterval = function(fn){ timers.push(fn); return timers.length; };
|
||||||
|
clearInterval = function(){};
|
||||||
|
async function api(){
|
||||||
|
calls.api++;
|
||||||
|
var e = new Error('Session belongs to a different profile');
|
||||||
|
e.status = STATUS;
|
||||||
|
e.body = JSON.stringify(BODY);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _run_poller(start_fn, status, body):
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node not installed")
|
||||||
|
fns = [
|
||||||
|
_extract_fn(SESSIONS_JS, "_sessionProfileMismatchFromError"),
|
||||||
|
_extract_fn(MESSAGES_JS, "_startApprovalFallbackPoll"),
|
||||||
|
_extract_fn(MESSAGES_JS, "stopApprovalPollingForSession"),
|
||||||
|
_extract_fn(MESSAGES_JS, "stopApprovalPolling"),
|
||||||
|
_extract_fn(MESSAGES_JS, "_startClarifyFallbackPoll"),
|
||||||
|
_extract_fn(MESSAGES_JS, "stopClarifyPollingForSession"),
|
||||||
|
_extract_fn(MESSAGES_JS, "stopClarifyPolling"),
|
||||||
|
]
|
||||||
|
script = (
|
||||||
|
_HARNESS.replace("STATUS", str(status)).replace("BODY", json.dumps(body))
|
||||||
|
+ "\n".join(fns)
|
||||||
|
+ f"""
|
||||||
|
(async () => {{
|
||||||
|
{start_fn}('sid1');
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
|
const pollingAfterFirst = {'_approvalPollingSessionId' if 'Approval' in start_fn else '_clarifyPollingSessionId'};
|
||||||
|
process.stdout.write(JSON.stringify({{calls, pollingAfterFirst}}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
out = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=30)
|
||||||
|
assert out.returncode == 0, out.stderr
|
||||||
|
return json.loads(out.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
_MISMATCH = {"error": "Session belongs to a different profile", "code": "session_profile_mismatch",
|
||||||
|
"session_id": "sid1", "profile": "deepseek"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_approval_poll_stops_on_profile_mismatch_409():
|
||||||
|
r = _run_poller("_startApprovalFallbackPoll", 409, _MISMATCH)
|
||||||
|
assert r["calls"]["api"] == 1
|
||||||
|
assert r["pollingAfterFirst"] is None, "approval poller must stop after a session_profile_mismatch 409"
|
||||||
|
assert r["calls"]["hide"] == 0, "a live approval card must stay standing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_approval_poll_keeps_running_on_other_errors():
|
||||||
|
r = _run_poller("_startApprovalFallbackPoll", 500, {"error": "boom"})
|
||||||
|
assert r["pollingAfterFirst"] == "sid1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clarify_poll_stops_quietly_on_profile_mismatch_409():
|
||||||
|
r = _run_poller("_startClarifyFallbackPoll", 409, _MISMATCH)
|
||||||
|
assert r["calls"]["api"] == 1
|
||||||
|
assert r["pollingAfterFirst"] is None, "clarify poller must stop after a session_profile_mismatch 409"
|
||||||
|
assert r["calls"]["warn"] == 0, "expected profile mismatch must not log '[clarify] pending poll failed'"
|
||||||
|
assert r["calls"]["hide"] == 0, "a live clarify card must stay standing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clarify_poll_still_warns_on_unexpected_errors():
|
||||||
|
r = _run_poller("_startClarifyFallbackPoll", 500, {"error": "boom"})
|
||||||
|
assert r["calls"]["warn"] == 1
|
||||||
|
assert r["pollingAfterFirst"] == "sid1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pwa_startup_is_not_preloaded_next_to_its_own_blocking_script():
|
||||||
|
assert '<script src="static/pwa-startup.js?v=__WEBUI_VERSION__"></script>' in INDEX_HTML
|
||||||
|
assert 'rel="preload" href="static/pwa-startup.js' not in INDEX_HTML
|
||||||
@@ -293,13 +293,11 @@ class TestIndexHtmlIntegration:
|
|||||||
are present on first paint.
|
are present on first paint.
|
||||||
"""
|
"""
|
||||||
src = INDEX.read_text(encoding="utf-8")
|
src = INDEX.read_text(encoding="utf-8")
|
||||||
preload_pos = src.find('href="static/pwa-startup.js?v=__WEBUI_VERSION__"')
|
|
||||||
script_pos = src.find('src="static/pwa-startup.js?v=__WEBUI_VERSION__"')
|
script_pos = src.find('src="static/pwa-startup.js?v=__WEBUI_VERSION__"')
|
||||||
ui_pos = src.find('static/ui.js?v=__WEBUI_VERSION__')
|
ui_pos = src.find('static/ui.js?v=__WEBUI_VERSION__')
|
||||||
assert preload_pos != -1, "index.html must preload the PWA startup helper"
|
|
||||||
assert script_pos != -1, "index.html must load the PWA startup helper"
|
assert script_pos != -1, "index.html must load the PWA startup helper"
|
||||||
assert ui_pos != -1, "index.html must load the main UI bundle"
|
assert ui_pos != -1, "index.html must load the main UI bundle"
|
||||||
assert preload_pos < ui_pos and script_pos < ui_pos, (
|
assert script_pos < ui_pos, (
|
||||||
"pwa-startup.js must run before ui.js so standalone/offline classes "
|
"pwa-startup.js must run before ui.js so standalone/offline classes "
|
||||||
"are available before the app shell paints"
|
"are available before the app shell paints"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user