Files
hermes-agent/apps/desktop/electron/bootstrap-repair-guard.test.ts
T
Kewe63 16e66e721f fix(desktop): break renderer-led reinstall loop on transient backend stalls
Issue #74874. The renderer's 'Repair' button treated every transient
backend GIL stall (event loop stalled ... ws ready frame send failed)
as a fatal backend fault, asking the bootstrap to force-reinstall +
restart, which then stalled again for the same reason — looping the
user through 30+ minutes of reinstall cycles.

Distinguish 'venv is genuinely broken' from 'backend is just transiently
stalled' before honouring a repair request. Probe the live backend
process (exitCode === null && signalCode === null) and an in-flight
repair-attempt counter:

  attempt <= 3 AND primary alive   → soft restart (skip installer)
  attempt <= 3 AND primary dead    → soft restart (verify before reinstall)
  attempt >  3                    → hard reinstall (escalate)

Counter resets on a clean backend.ready so a later, unrelated failure
episode starts at attempt 1. The guard is a pure helper (decideBootstrap
Repair in electron/bootstrap-repair-guard.ts) so the decision logic is
unit-tested in isolation; main.ts only wires the existing flag and
counters to it.

Refs #74874
2026-07-31 22:33:53 -07:00

110 lines
2.9 KiB
TypeScript

import assert from 'node:assert/strict'
import { test } from 'vitest'
import { decideBootstrapRepair } from './bootstrap-repair-guard'
test('first soft attempt with alive backend returns soft restart', () => {
const decision = decideBootstrapRepair({
attempt: 1,
primaryBackendAlive: true
})
assert.equal(decision.hardReinstall, false)
assert.equal(decision.attempt, 1)
assert.match(decision.reason, /still alive/)
assert.match(decision.reason, /1\/3/)
})
test('first attempt with dead backend still returns soft restart', () => {
const decision = decideBootstrapRepair({
attempt: 1,
primaryBackendAlive: false
})
assert.equal(decision.hardReinstall, false)
assert.match(decision.reason, /has exited/)
})
test('soft restart budget exhausts at maxSoftAttempts+1 and escalates', () => {
const decision = decideBootstrapRepair({
attempt: 4,
maxSoftAttempts: 3,
primaryBackendAlive: true
})
assert.equal(decision.hardReinstall, true)
assert.equal(decision.attempt, 4)
assert.match(decision.reason, /exceeds soft-restart budget/)
})
test('attempt exactly at maxSoftAttempts is still soft', () => {
const decision = decideBootstrapRepair({
attempt: 3,
maxSoftAttempts: 3,
primaryBackendAlive: true
})
assert.equal(decision.hardReinstall, false)
assert.equal(decision.attempt, 3)
})
test('custom maxSoftAttempts is honored', () => {
const soft = decideBootstrapRepair({
attempt: 5,
maxSoftAttempts: 10,
primaryBackendAlive: true
})
assert.equal(soft.hardReinstall, false)
const hard = decideBootstrapRepair({
attempt: 11,
maxSoftAttempts: 10,
primaryBackendAlive: false
})
assert.equal(hard.hardReinstall, true)
})
test('default maxSoftAttempts is 3', () => {
// Probe the default indirectly: attempt 4 with no override must escalate.
const decision = decideBootstrapRepair({
attempt: 4,
primaryBackendAlive: true
})
assert.equal(decision.hardReinstall, true)
})
test('fractional or zero attempts are clamped to 1', () => {
const zeroDecision = decideBootstrapRepair({
attempt: 0,
primaryBackendAlive: true
})
assert.equal(zeroDecision.attempt, 1)
assert.equal(zeroDecision.hardReinstall, false)
const fractionalDecision = decideBootstrapRepair({
attempt: 2.7,
primaryBackendAlive: true
})
assert.equal(fractionalDecision.attempt, 2)
assert.equal(fractionalDecision.hardReinstall, false)
})
test('alive=false on a high attempt number still escalates (defense in depth)', () => {
// A dead backend should normally be handled by the renderer before it
// reaches the repair path, but if it does reach us with a high attempt
// count we still escalate — never silently keep soft-restarting.
const decision = decideBootstrapRepair({
attempt: 5,
maxSoftAttempts: 3,
primaryBackendAlive: false
})
assert.equal(decision.hardReinstall, true)
})