fix: surface stale Kanban client recovery

This commit is contained in:
Michael Lam
2026-05-07 10:23:32 -07:00
committed by nesquena-hermes
parent bc732995c4
commit bb75707331
6 changed files with 172 additions and 7 deletions

View File

@@ -745,6 +745,18 @@ def _list_boards_payload(parsed):
current = kb.get_current_board()
except Exception:
current = "default"
visible_slugs = {(_board_meta_dict(meta).get("slug")) for meta in boards}
default_slug = getattr(kb, "DEFAULT_BOARD", "default")
if current not in visible_slugs:
# The on-disk active-board pointer can outlive an archived/deleted board
# when another CLI/WebUI process removes it. Surface a valid current
# board instead of letting the frontend pin every subsequent request to
# a ghost slug and fail with an opaque 404.
try:
kb.clear_current_board()
except Exception:
pass
current = default_slug
out = []
for raw_meta in boards:
meta = _board_meta_dict(raw_meta)

View File

@@ -590,6 +590,19 @@ from api.agent_health import build_agent_health_payload
from api.system_health import build_system_health_payload
def _kanban_unknown_endpoint(handler, parsed, method: str) -> bool:
"""Return a Kanban-specific 404 for stale clients/obsolete endpoint shapes."""
return bad(
handler,
(
f"unknown Kanban endpoint: {method} {parsed.path}. "
"If this appeared after a WebUI update, your browser may be running "
"a stale cached bundle; use Hard refresh now, then reopen Kanban."
),
status=404,
) or True
def _clear_stale_stream_state(session) -> bool:
"""Clear persisted streaming flags when the in-memory stream no longer exists.
@@ -2616,7 +2629,9 @@ def handle_get(handler, parsed) -> bool:
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_get
return handle_kanban_get(handler, parsed)
if handle_kanban_get(handler, parsed):
return True
return _kanban_unknown_endpoint(handler, parsed, "GET")
if parsed.path == "/api/wiki/status":
return _handle_llm_wiki_status(handler, parsed)
if parsed.path == "/api/logs":
@@ -3414,7 +3429,9 @@ def handle_post(handler, parsed) -> bool:
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_post
return handle_kanban_post(handler, parsed, body)
if handle_kanban_post(handler, parsed, body):
return True
return _kanban_unknown_endpoint(handler, parsed, "POST")
if parsed.path == "/api/dashboard/config":
from api import dashboard_probe
@@ -4590,7 +4607,9 @@ def handle_patch(handler, parsed) -> bool:
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_patch
return handle_kanban_patch(handler, parsed, body)
if handle_kanban_patch(handler, parsed, body):
return True
return _kanban_unknown_endpoint(handler, parsed, "PATCH")
return False
@@ -4602,7 +4621,9 @@ def handle_delete(handler, parsed) -> bool:
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_delete
return handle_kanban_delete(handler, parsed, body)
if handle_kanban_delete(handler, parsed, body):
return True
return _kanban_unknown_endpoint(handler, parsed, "DELETE")
return False
# ── GET route helpers ─────────────────────────────────────────────────────────

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1189,11 +1189,49 @@ function _kanbanCard(task, status){
</article>`;
}
async function hardRefreshWebUIClient(){
try {
if (navigator.serviceWorker) {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map(r => r.unregister()));
}
} catch(_) {}
try {
if (window.caches) {
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k)));
}
} catch(_) {}
window.location.reload();
}
function _kanbanLooksLikeStaleClientError(err){
const msg = String((err && err.message) || err || '').toLowerCase();
return !!(err && err.status === 404 && (
msg === 'not found' ||
msg.includes('unknown kanban endpoint') ||
msg.includes('stale cached bundle')
));
}
function _kanbanUnavailableHtml(err){
const raw = String((err && err.message) || err || '');
if (_kanbanLooksLikeStaleClientError(err)) {
return `<div class="main-view-empty"><div class="main-view-empty-title">Kanban needs a hard refresh</div><div class="main-view-empty-subtitle">The server rejected an obsolete Kanban endpoint. This usually means the browser or Mac app is still running a stale cached WebUI bundle after an update.</div><button class="btn primary" type="button" onclick="hardRefreshWebUIClient()">Hard refresh now</button><div class="main-view-empty-subtitle">Original error: ${esc(raw || 'not found')}</div></div>`;
}
const msg = `${esc(t('kanban_unavailable'))}: ${esc(raw)}`;
return `<div class="main-view-empty"><div class="main-view-empty-title">${msg}</div></div>`;
}
async function loadKanban(animate){
const board = $('kanbanBoard');
const list = $('kanbanList');
try {
if (animate && board) board.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:13px">${esc(t('loading'))}</div>`;
// Resolve the active board before board-scoped requests. If another CLI or
// tab archived the previous board, /boards can fall back to default instead
// of leaving config/board pinned to a ghost slug.
await loadKanbanBoards();
const config = await api('/api/kanban/config' + _kanbanBoardQuery());
let assignees = null;
try { assignees = await api('/api/kanban/assignees' + _kanbanBoardQuery()); } catch(e) { assignees = null; }
@@ -1230,9 +1268,9 @@ async function loadKanban(animate){
_kanbanStartPolling();
_kanbanRenderBoard();
} catch(e) {
const msg = `${esc(t('kanban_unavailable'))}: ${esc(e.message || e)}`;
if (board) board.innerHTML = `<div class="main-view-empty"><div class="main-view-empty-title">${msg}</div></div>`;
if (list) list.innerHTML = `<div class="kanban-empty">${msg}</div>`;
const html = _kanbanUnavailableHtml(e);
if (board) board.innerHTML = html;
if (list) list.innerHTML = html;
}
}
@@ -1687,6 +1725,8 @@ async function loadKanbanBoards(){
let active = serverCurrent;
if (saved && boards.some(b => b.slug === saved)) {
active = saved;
} else if (saved) {
_kanbanSetSavedBoard('default');
}
_kanbanCurrentBoard = (active === 'default') ? null : active;
// The switcher is visible whenever ≥1 non-default board exists OR the

View File

@@ -0,0 +1,76 @@
"""Regression coverage for #1823 Kanban stale-client/board-pointer failures."""
from __future__ import annotations
import io
import json
from types import SimpleNamespace
from urllib.parse import urlparse
from api import routes
ROOT = __import__("pathlib").Path(__file__).resolve().parents[1]
PANELS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
ROUTES = (ROOT / "api" / "routes.py").read_text(encoding="utf-8")
class _FakeHandler:
def __init__(self):
self.status = None
self.headers = {}
self.response_headers = []
self.wfile = io.BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.response_headers.append((key, value))
def end_headers(self):
pass
def body_json(self):
return json.loads(self.wfile.getvalue().decode("utf-8"))
def test_unknown_kanban_endpoint_get_returns_stale_client_diagnostic():
"""Obsolete/stale JS should not collapse to a bare `not found` 404."""
handler = _FakeHandler()
handled = routes.handle_get(handler, urlparse("/api/kanban/obsolete-shape"))
assert handled is True
assert handler.status == 404
error = handler.body_json()["error"]
assert error != "not found"
assert "unknown Kanban endpoint: GET /api/kanban/obsolete-shape" in error
assert "stale cached bundle" in error
assert "Hard refresh now" in error
def test_unknown_kanban_endpoint_routes_are_wrapped_for_all_methods():
assert 'return _kanban_unknown_endpoint(handler, parsed, "GET")' in ROUTES
assert 'return _kanban_unknown_endpoint(handler, parsed, "POST")' in ROUTES
assert 'return _kanban_unknown_endpoint(handler, parsed, "PATCH")' in ROUTES
assert 'return _kanban_unknown_endpoint(handler, parsed, "DELETE")' in ROUTES
def test_kanban_stale_client_error_renders_hard_refresh_escape_hatch():
assert "function _kanbanLooksLikeStaleClientError(err)" in PANELS
assert "err.status === 404" in PANELS
assert "msg === 'not found'" in PANELS
assert "msg.includes('unknown kanban endpoint')" in PANELS
assert "Kanban needs a hard refresh" in PANELS
assert "Hard refresh now" in PANELS
assert "navigator.serviceWorker.getRegistrations()" in PANELS
assert "caches.keys()" in PANELS
assert "window.location.reload()" in PANELS
def test_kanban_load_resolves_board_before_board_scoped_requests():
boards_pos = PANELS.find("await loadKanbanBoards();")
config_pos = PANELS.find("api('/api/kanban/config' + _kanbanBoardQuery())")
assert boards_pos != -1
assert config_pos != -1
assert boards_pos < config_pos
assert "_kanbanSetSavedBoard('default');" in PANELS

View File

@@ -389,6 +389,22 @@ def test_kanban_board_payload_exposes_read_only_board(monkeypatch):
assert any(task["id"] == "t_1" and task["title"] == "Read-only board target" for task in all_tasks)
def test_board_pointer_drift_falls_back_to_default(monkeypatch):
bridge = _load_bridge(monkeypatch)
fake_kanban = sys.modules["hermes_cli.kanban_db"]
fake_kanban.boards = {
"default": {"slug": "default", "name": "Default board", "archived": False},
"active": {"slug": "active", "name": "Active board", "archived": False},
}
fake_kanban.set_current_board("ghost")
data = bridge._list_boards_payload(_parsed(path="/api/kanban/boards"))
assert data["current"] == "default"
assert fake_kanban.get_current_board() == "default"
assert any(board["slug"] == "default" and board["is_current"] for board in data["boards"])
def test_kanban_task_detail_payload_exposes_comments_events_links_and_runs(monkeypatch):
bridge = _load_bridge(monkeypatch)