fix: update banner — conflict recovery path + server self-restart after update (#816)
* fix: update banner conflict recovery + server self-restart after update (#813 #814) * fix(update): restart must wait for in-flight update + reset force button on retry Two defects in the update banner flow found during review of PR #816: 1. Two-target race (webui + agent sequential) The client posts targets sequentially: webui succeeds and schedules a restart timer (2 s delay); client then posts agent; server begins agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull, killing the agent update and closing the client connection. User sees "Update failed (agent): Failed to fetch" even though webui did update, and the agent repo is in an unknown partial state. Fix: _schedule_restart() now blocks on _apply_lock before calling os.execv. If a second update is in flight when the timer fires, the restart thread waits until it completes. If nothing is in flight the lock acquire is instant, so no-op updates still restart immediately. 2. Stale force-update button across retries _showUpdateError sets btnForceUpdate to display:inline-block when res.conflict / res.diverged. Nothing resets it on the next retry, so a subsequent non-conflict error (e.g. network) leaves the stale force button visible pointing at the previous target. Fix: applyUpdates() now hides the force button and clears its data-target at the start of each attempt. Tests: - test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a helper thread, verifies execv is delayed until the lock is released. - test_schedule_restart_still_fires_when_no_update_in_flight: sanity check that the common path still works with no contention. - test_apply_updates_resets_force_button_at_start: regression guard that the reset appears before the update loop begins. Full suite: 1683 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(update): hold _apply_lock through execv + fix banner error layout Two fixes from Opus review: 1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern acquired _apply_lock, released it, then called os.execv — leaving a brief window where a new update could start between release and execv. Fixed by moving os.execv inside the 'with _apply_lock:' block so the process is replaced while still holding the lock; no new update can acquire it. 2. Banner CSS layout (static/index.html): #updateError was a direct flex child of .update-banner (display:flex row), so long error messages sat inline between #updateMsg and the buttons instead of below the message. Wrapped #updateMsg + #updateError in a flex-column container so errors stack vertically under the status line. * docs: add v0.50.134 CHANGELOG entry --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -212,10 +212,14 @@
|
||||
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
||||
</div>
|
||||
<div class="update-banner" id="updateBanner">
|
||||
<span id="updateMsg"></span>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0">
|
||||
<div style="display:flex;flex-direction:column;flex:1;min-width:0">
|
||||
<span id="updateMsg"></span>
|
||||
<div id="updateError" style="display:none;font-size:12px;color:var(--error,#e05);margin-top:4px;word-break:break-word"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap">
|
||||
<button class="update-btn" onclick="dismissUpdate()">Later</button>
|
||||
<button class="update-btn update-primary" id="btnApplyUpdate" onclick="applyUpdates()">Update Now</button>
|
||||
<button class="update-btn" id="btnForceUpdate" style="display:none;background:var(--error,#e05);color:#fff;border-color:var(--error,#e05)" onclick="forceUpdate(this)">Force update</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reconnect-banner" id="reconnectBanner">
|
||||
|
||||
61
static/ui.js
61
static/ui.js
@@ -1051,6 +1051,12 @@ function dismissUpdate(){
|
||||
async function applyUpdates(){
|
||||
const btn=$('btnApplyUpdate');
|
||||
if(btn){btn.disabled=true;btn.textContent='Updating\u2026';}
|
||||
const errEl=$('updateError');
|
||||
if(errEl){errEl.style.display='none';errEl.textContent='';}
|
||||
// Hide any leftover force-update button from a prior conflict so a fresh
|
||||
// retry starts clean (otherwise stale state points at the wrong target).
|
||||
const forceBtnReset=$('btnForceUpdate');
|
||||
if(forceBtnReset){forceBtnReset.style.display='none';forceBtnReset.dataset.target='';}
|
||||
const targets=[];
|
||||
if(window._updateData?.webui?.behind>0) targets.push('webui');
|
||||
if(window._updateData?.agent?.behind>0) targets.push('agent');
|
||||
@@ -1058,20 +1064,67 @@ async function applyUpdates(){
|
||||
for(const target of targets){
|
||||
const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify({target})});
|
||||
if(!res.ok){
|
||||
showToast('Update failed ('+target+'): '+(res.message||'unknown error'));
|
||||
_showUpdateError(target,res);
|
||||
if(btn){btn.disabled=false;btn.textContent='Update Now';}
|
||||
return;
|
||||
}
|
||||
}
|
||||
showToast('Updated! Reloading\u2026');
|
||||
showToast('Updated! Restarting\u2026');
|
||||
sessionStorage.removeItem('hermes-update-checked');
|
||||
sessionStorage.removeItem('hermes-update-dismissed');
|
||||
setTimeout(()=>location.reload(),1500);
|
||||
setTimeout(()=>location.reload(),2500);
|
||||
}catch(e){
|
||||
showToast('Update failed: '+e.message);
|
||||
if(errEl){errEl.textContent='Update failed: '+e.message;errEl.style.display='block';}
|
||||
else showToast('Update failed: '+e.message);
|
||||
if(btn){btn.disabled=false;btn.textContent='Update Now';}
|
||||
}
|
||||
}
|
||||
function _showUpdateError(target,res){
|
||||
const errEl=$('updateError');
|
||||
const forceBtn=$('btnForceUpdate');
|
||||
const msg='Update failed ('+target+'): '+(res.message||'unknown error');
|
||||
if(errEl){
|
||||
errEl.textContent=msg;
|
||||
errEl.style.display='block';
|
||||
} else {
|
||||
showToast(msg);
|
||||
}
|
||||
// Show "Force update" button when the error is recoverable by a hard reset
|
||||
if(forceBtn&&(res.conflict||res.diverged)){
|
||||
forceBtn.dataset.target=target;
|
||||
forceBtn.style.display='inline-block';
|
||||
}
|
||||
}
|
||||
async function forceUpdate(btn){
|
||||
const target=btn&&btn.dataset.target;
|
||||
if(!target) return;
|
||||
const confirmed=await showConfirmDialog({
|
||||
title:'Force update '+target+'?',
|
||||
message:'This will discard all local changes in the '+target+' repo and reset to the latest remote version. This cannot be undone.',
|
||||
confirmLabel:'Force update',
|
||||
danger:true,
|
||||
focusCancel:true,
|
||||
});
|
||||
if(!confirmed) return;
|
||||
btn.disabled=true;btn.textContent='Force updating\u2026';
|
||||
const errEl=$('updateError');
|
||||
if(errEl){errEl.style.display='none';}
|
||||
try{
|
||||
const res=await api('/api/updates/force',{method:'POST',body:JSON.stringify({target})});
|
||||
if(!res.ok){
|
||||
if(errEl){errEl.textContent='Force update failed: '+(res.message||'unknown error');errEl.style.display='block';}
|
||||
btn.disabled=false;btn.textContent='Force update';
|
||||
return;
|
||||
}
|
||||
showToast('Force updated! Restarting\u2026');
|
||||
sessionStorage.removeItem('hermes-update-checked');
|
||||
sessionStorage.removeItem('hermes-update-dismissed');
|
||||
setTimeout(()=>location.reload(),2500);
|
||||
}catch(e){
|
||||
if(errEl){errEl.textContent='Force update failed: '+e.message;errEl.style.display='block';}
|
||||
btn.disabled=false;btn.textContent='Force update';
|
||||
}
|
||||
}
|
||||
|
||||
function getPendingSessionMessage(session){
|
||||
const text=String(session?.pending_user_message||'').trim();
|
||||
|
||||
Reference in New Issue
Block a user