Merge pull request #2770 from nesquena/release/stage-pr2766
Release CO: v0.51.117 (stage-pr2766 — 1-PR — in-flight recovery storage quota-safe)
This commit is contained in:
5 files changed
+199
-4
No files matched your search
@@ -3,6 +3,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.117] — 2026-05-22 — Release CO (stage-pr2766 — 1-PR — in-flight recovery storage quota-safe)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2766** by @george-andraws — Make in-flight recovery storage quota-safe so a full browser `localStorage` budget no longer blocks chat submission with `QuotaExceededError: Failed to execute 'setItem' on 'Storage': Setting the value of 'hermes-webui-inflight' exceeded the quota.` The fix has four parts: (1) compact recovery snapshots before writing to keep only recent messages, tool calls, uploaded-file metadata, stream id, and updated timestamp; (2) truncate individual large strings per field (`60000` chars default) and prune the serialized payload (`1500000` chars default); (3) catch quota errors specifically on `markInflight()`, drop the larger `hermes-webui-inflight-state` key, then retry the tiny marker write; (4) expose 5 new configurable settings (`inflight_state_max_sessions`, `_max_messages`, `_max_tool_calls`, `_max_string_chars`, `_max_json_chars`) with int-range validation so operators can tune the budget. Self-healing on the very first chat submit after upgrade for users with already-quota-exhausted storage — no manual reload required. Graceful degradation if storage is still too full even after compaction (clears recovery snapshots but never blocks chat submit).
|
||||
|
||||
## [v0.51.116] — 2026-05-22 — Release CN (stage-pr2676 — 1-PR — per-skill enable/disable toggle in Skills panel, CLI-parity with `hermes skills config`)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -4355,6 +4355,11 @@ _SETTINGS_DEFAULTS = {
|
||||
"session_jump_buttons": False, # show Start/End transcript jump pills
|
||||
"session_endless_scroll": False, # auto-load older transcript pages while scrolling upward
|
||||
"pinned_sessions_limit": 3, # maximum active pinned sessions shown in the sidebar
|
||||
"inflight_state_max_sessions": 8, # max active-stream recovery snapshots kept in browser localStorage
|
||||
"inflight_state_max_messages": 24, # max recent messages kept per recovery snapshot
|
||||
"inflight_state_max_tool_calls": 48, # max recent tool-call records kept per recovery snapshot
|
||||
"inflight_state_max_string_chars": 60000, # max string length kept inside a recovery snapshot field
|
||||
"inflight_state_max_json_chars": 1500000, # max serialized recovery snapshot payload before pruning
|
||||
"hidden_tabs": [], # sidebar tab panel names hidden by user (e.g. ["tasks","kanban"]); chat and settings are always visible
|
||||
"language": "en", # UI locale code; must match a key in static/i18n.js LOCALES
|
||||
"bot_name": os.getenv(
|
||||
@@ -4485,6 +4490,11 @@ _SETTINGS_ENUM_VALUES = {
|
||||
}
|
||||
_SETTINGS_INT_RANGES = {
|
||||
"pinned_sessions_limit": (1, 99),
|
||||
"inflight_state_max_sessions": (1, 25),
|
||||
"inflight_state_max_messages": (1, 100),
|
||||
"inflight_state_max_tool_calls": (1, 200),
|
||||
"inflight_state_max_string_chars": (1000, 500000),
|
||||
"inflight_state_max_json_chars": (100000, 4000000),
|
||||
}
|
||||
_SETTINGS_BOOL_KEYS = {
|
||||
"onboarding_completed",
|
||||
|
||||
@@ -1459,6 +1459,13 @@ function applyBotName(){
|
||||
window._simplifiedToolCalling=s.simplified_tool_calling!==false;
|
||||
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
|
||||
window._pinnedSessionsLimit=parseInt(s.pinned_sessions_limit||3,10)||3;
|
||||
window._inflightStateLimits={
|
||||
maxSessions:parseInt(s.inflight_state_max_sessions||8,10)||8,
|
||||
messages:parseInt(s.inflight_state_max_messages||24,10)||24,
|
||||
toolCalls:parseInt(s.inflight_state_max_tool_calls||48,10)||48,
|
||||
stringChars:parseInt(s.inflight_state_max_string_chars||60000,10)||60000,
|
||||
jsonChars:parseInt(s.inflight_state_max_json_chars||1500000,10)||1500000,
|
||||
};
|
||||
window._busyInputMode=(s.busy_input_mode||'queue');
|
||||
window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
|
||||
window._botName=s.bot_name||'Hermes';
|
||||
|
||||
+98
-4
@@ -4068,6 +4068,29 @@ function autoReadLastAssistant(){
|
||||
// ── Reconnect banner (B4/B5: reload resilience) ──
|
||||
const INFLIGHT_KEY = 'hermes-webui-inflight'; // localStorage key for in-flight session tracking
|
||||
const INFLIGHT_STATE_KEY = 'hermes-webui-inflight-state'; // localStorage snapshots for mid-stream reload recovery
|
||||
const INFLIGHT_STATE_DEFAULT_LIMITS = {
|
||||
maxSessions:8,
|
||||
messages:24,
|
||||
toolCalls:48,
|
||||
stringChars:60000,
|
||||
jsonChars:1500000,
|
||||
};
|
||||
|
||||
function _boundedInflightInt(value, fallback, min, max){
|
||||
const n=parseInt(value,10);
|
||||
if(!Number.isFinite(n)) return fallback;
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
function _inflightStateLimits(){
|
||||
const configured=(typeof window!=='undefined'&&window._inflightStateLimits&&typeof window._inflightStateLimits==='object')?window._inflightStateLimits:{};
|
||||
return {
|
||||
maxSessions:_boundedInflightInt(configured.maxSessions, INFLIGHT_STATE_DEFAULT_LIMITS.maxSessions, 1, 25),
|
||||
messages:_boundedInflightInt(configured.messages, INFLIGHT_STATE_DEFAULT_LIMITS.messages, 1, 100),
|
||||
toolCalls:_boundedInflightInt(configured.toolCalls, INFLIGHT_STATE_DEFAULT_LIMITS.toolCalls, 1, 200),
|
||||
stringChars:_boundedInflightInt(configured.stringChars, INFLIGHT_STATE_DEFAULT_LIMITS.stringChars, 1000, 500000),
|
||||
jsonChars:_boundedInflightInt(configured.jsonChars, INFLIGHT_STATE_DEFAULT_LIMITS.jsonChars, 100000, 4000000),
|
||||
};
|
||||
}
|
||||
|
||||
function _readInflightStateMap(){
|
||||
try{
|
||||
@@ -4078,13 +4101,75 @@ function _readInflightStateMap(){
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function _isStorageQuotaError(err){
|
||||
return !!err && (
|
||||
err.name==='QuotaExceededError' ||
|
||||
err.name==='NS_ERROR_DOM_QUOTA_REACHED' ||
|
||||
err.code===22 ||
|
||||
err.code===1014
|
||||
);
|
||||
}
|
||||
function _truncateInflightValue(value, maxChars){
|
||||
const limits=_inflightStateLimits();
|
||||
const stringLimit=_boundedInflightInt(maxChars, limits.stringChars, 1000, 500000);
|
||||
if(typeof value==='string'){
|
||||
if(value.length<=stringLimit) return value;
|
||||
return value.slice(0,stringLimit)+'\n\n[truncated for browser recovery storage]';
|
||||
}
|
||||
if(Array.isArray(value)) return value.map(v=>_truncateInflightValue(v, Math.max(2000, Math.floor(stringLimit/2))));
|
||||
if(value&&typeof value==='object'){
|
||||
const out={};
|
||||
for(const [k,v] of Object.entries(value)) out[k]=_truncateInflightValue(v, stringLimit);
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function _compactInflightState(state){
|
||||
const limits=_inflightStateLimits();
|
||||
const messages=Array.isArray(state.messages)?state.messages.slice(-limits.messages):[];
|
||||
const toolCalls=Array.isArray(state.toolCalls)?state.toolCalls.slice(-limits.toolCalls):[];
|
||||
return _truncateInflightValue({
|
||||
streamId:state.streamId||null,
|
||||
messages,
|
||||
uploaded:Array.isArray(state.uploaded)?state.uploaded.slice(-20):[],
|
||||
toolCalls,
|
||||
}, limits.stringChars);
|
||||
}
|
||||
function _writeInflightStateMap(all){
|
||||
const limits=_inflightStateLimits();
|
||||
const entries=Object.entries(all||{})
|
||||
.sort((a,b)=>Number(b[1]&&b[1].updated_at||0)-Number(a[1]&&a[1].updated_at||0))
|
||||
.slice(0,limits.maxSessions);
|
||||
const compact={};
|
||||
for(const [sid,entry] of entries) compact[sid]=entry;
|
||||
let json=JSON.stringify(compact);
|
||||
if(json.length>limits.jsonChars){
|
||||
const current=entries[0];
|
||||
json=JSON.stringify(current?{[current[0]]:current[1]}:{});
|
||||
}
|
||||
if(json.length>limits.jsonChars){
|
||||
localStorage.removeItem(INFLIGHT_STATE_KEY);
|
||||
return false;
|
||||
}
|
||||
localStorage.setItem(INFLIGHT_STATE_KEY,json);
|
||||
return true;
|
||||
}
|
||||
function saveInflightState(sid, state){
|
||||
if(!sid||!state) return;
|
||||
const entry={..._compactInflightState(state),updated_at:Date.now()};
|
||||
try{
|
||||
const all=_readInflightStateMap();
|
||||
all[sid]={...state,updated_at:Date.now()};
|
||||
localStorage.setItem(INFLIGHT_STATE_KEY, JSON.stringify(all));
|
||||
}catch(_){ }
|
||||
all[sid]=entry;
|
||||
_writeInflightStateMap(all);
|
||||
}catch(err){
|
||||
if(!_isStorageQuotaError(err)) return;
|
||||
try{
|
||||
localStorage.removeItem(INFLIGHT_STATE_KEY);
|
||||
_writeInflightStateMap({[sid]:entry});
|
||||
}catch(_){
|
||||
try{localStorage.removeItem(INFLIGHT_STATE_KEY);}catch(__){}
|
||||
}
|
||||
}
|
||||
}
|
||||
function loadInflightState(sid, streamId){
|
||||
if(!sid) return null;
|
||||
@@ -4171,7 +4256,16 @@ function restoreLiveTurnHtmlForSession(sid){
|
||||
}
|
||||
|
||||
function markInflight(sid, streamId) {
|
||||
localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now()}));
|
||||
const payload=JSON.stringify({sid, streamId, ts: Date.now()});
|
||||
try{
|
||||
localStorage.setItem(INFLIGHT_KEY, payload);
|
||||
}catch(err){
|
||||
if(!_isStorageQuotaError(err)) return;
|
||||
try{
|
||||
localStorage.removeItem(INFLIGHT_STATE_KEY);
|
||||
localStorage.setItem(INFLIGHT_KEY, payload);
|
||||
}catch(_){}
|
||||
}
|
||||
}
|
||||
function clearInflight() {
|
||||
localStorage.removeItem(INFLIGHT_KEY);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Regression coverage for browser in-flight localStorage quota handling."""
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
|
||||
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
marker = f"function {name}"
|
||||
start = src.index(marker)
|
||||
brace = src.index("{", start)
|
||||
depth = 1
|
||||
i = brace + 1
|
||||
while depth and i < len(src):
|
||||
if src[i] == "{":
|
||||
depth += 1
|
||||
elif src[i] == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
return src[brace + 1 : i - 1]
|
||||
|
||||
|
||||
def test_inflight_state_is_compacted_before_localstorage_write():
|
||||
"""Persisted recovery state must stay bounded instead of storing full long sessions."""
|
||||
save_body = _function_body(UI_JS, "saveInflightState")
|
||||
compact_body = _function_body(UI_JS, "_compactInflightState")
|
||||
|
||||
assert "const entry={..._compactInflightState(state),updated_at:Date.now()};" in save_body
|
||||
assert "const limits=_inflightStateLimits();" in compact_body
|
||||
assert ".slice(-limits.messages)" in compact_body
|
||||
assert ".slice(-limits.toolCalls)" in compact_body
|
||||
assert "limits.jsonChars" in UI_JS
|
||||
|
||||
|
||||
def test_inflight_state_limits_are_configurable_from_settings():
|
||||
"""Recovery snapshots should be bounded by settings, not hardcoded at 3 sessions / 8 messages."""
|
||||
assert '\"inflight_state_max_sessions\": 8' in CONFIG_PY
|
||||
assert '\"inflight_state_max_messages\": 24' in CONFIG_PY
|
||||
assert '\"inflight_state_max_tool_calls\": 48' in CONFIG_PY
|
||||
assert '\"inflight_state_max_string_chars\": 60000' in CONFIG_PY
|
||||
assert '\"inflight_state_max_json_chars\": 1500000' in CONFIG_PY
|
||||
assert '\"inflight_state_max_sessions\": (1, 25)' in CONFIG_PY
|
||||
assert '\"inflight_state_max_messages\": (1, 100)' in CONFIG_PY
|
||||
assert '\"inflight_state_max_tool_calls\": (1, 200)' in CONFIG_PY
|
||||
assert '\"inflight_state_max_string_chars\": (1000, 500000)' in CONFIG_PY
|
||||
assert '\"inflight_state_max_json_chars\": (100000, 4000000)' in CONFIG_PY
|
||||
assert "window._inflightStateLimits={" in BOOT_JS
|
||||
assert "maxSessions:parseInt(s.inflight_state_max_sessions||8,10)||8" in BOOT_JS
|
||||
assert "messages:parseInt(s.inflight_state_max_messages||24,10)||24" in BOOT_JS
|
||||
assert "function _inflightStateLimits()" in UI_JS
|
||||
assert "window._inflightStateLimits" in UI_JS
|
||||
assert "INFLIGHT_STATE_MAX_SESSIONS = 3" not in UI_JS
|
||||
assert "INFLIGHT_STATE_MAX_MESSAGES = 8" not in UI_JS
|
||||
|
||||
|
||||
def test_inflight_marker_write_handles_quota_by_dropping_recovery_snapshots():
|
||||
"""The tiny active-stream marker must not crash submit when recovery snapshots fill quota."""
|
||||
mark_body = _function_body(UI_JS, "markInflight")
|
||||
|
||||
assert "try{" in mark_body
|
||||
assert "localStorage.setItem(INFLIGHT_KEY, payload);" in mark_body
|
||||
assert "_isStorageQuotaError(err)" in mark_body
|
||||
assert "localStorage.removeItem(INFLIGHT_STATE_KEY);" in mark_body
|
||||
assert mark_body.index("localStorage.removeItem(INFLIGHT_STATE_KEY);") < mark_body.rindex(
|
||||
"localStorage.setItem(INFLIGHT_KEY, payload);"
|
||||
)
|
||||
|
||||
|
||||
def test_save_inflight_state_clears_snapshots_when_quota_retry_fails():
|
||||
"""Quota failures should degrade recovery, not preserve a storage-filling blob."""
|
||||
save_body = _function_body(UI_JS, "saveInflightState")
|
||||
|
||||
assert "catch(err)" in save_body
|
||||
assert "if(!_isStorageQuotaError(err)) return;" in save_body
|
||||
assert "localStorage.removeItem(INFLIGHT_STATE_KEY);" in save_body
|
||||
assert "_writeInflightStateMap({[sid]:entry});" in save_body
|
||||
Reference in new issue
Block a user