mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
opentui(harden): surface gateway exit/recovery + transport errors to the UI
This commit is contained in:
parent
c29402d731
commit
84b77f68e5
4 changed files with 149 additions and 1 deletions
|
|
@ -195,6 +195,19 @@ const GatewayProtocolError = Schema.Struct({
|
|||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ preview: opt(Str) }))
|
||||
})
|
||||
// gateway lifecycle recovery (auto-heal): the child exited (crash/kill) and the
|
||||
// transport is respawning+resuming the session. Surfaced so the frozen spinner
|
||||
// clears and the user sees the in-flight reply was lost (see store cases).
|
||||
const GatewayExited = Schema.Struct({
|
||||
type: Schema.Literal('gateway.exited'),
|
||||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ reason: opt(Str), code: opt(Schema.Number), signal: opt(Str) }))
|
||||
})
|
||||
const GatewayRecovering = Schema.Struct({
|
||||
type: Schema.Literal('gateway.recovering'),
|
||||
session_id: opt(Str),
|
||||
payload: opt(Schema.Struct({ attempt: opt(Schema.Number), delay_ms: opt(Schema.Number) }))
|
||||
})
|
||||
|
||||
// ── The union ─────────────────────────────────────────────────────────
|
||||
export const GatewayEventSchema = Schema.Union([
|
||||
|
|
@ -232,7 +245,9 @@ export const GatewayEventSchema = Schema.Union([
|
|||
ErrorEvent,
|
||||
GatewayStderr,
|
||||
GatewayStartTimeout,
|
||||
GatewayProtocolError
|
||||
GatewayProtocolError,
|
||||
GatewayExited,
|
||||
GatewayRecovering
|
||||
]).pipe(Schema.toTaggedUnion('type'))
|
||||
|
||||
/** The decoded, typed event. Inferred from the schema — never hand-declared. */
|
||||
|
|
|
|||
|
|
@ -310,6 +310,18 @@ export function createSessionStore() {
|
|||
// queue here and replay after the snapshot is reconciled (opencode sync-v2).
|
||||
let buffering: GatewayEvent[] | null = null
|
||||
|
||||
// Anti-flood for `gateway.stderr`: a crashing child can emit a torrent of
|
||||
// stderr lines, so we do NOT push each to the transcript. Instead we keep a
|
||||
// small ring of the most-recent lines and only surface a TAIL of it when a
|
||||
// failure event (start_timeout / exited) actually needs the diagnostic
|
||||
// context — so a healthy-but-chatty gateway never spams the chat.
|
||||
const STDERR_RING_LIMIT = 20
|
||||
const STDERR_TAIL = 5
|
||||
const stderrRing: string[] = []
|
||||
function stderrTail(): string {
|
||||
return stderrRing.slice(-STDERR_TAIL).join('\n')
|
||||
}
|
||||
|
||||
function setSkin(skin: GatewaySkinDecoded | undefined): void {
|
||||
setState('theme', themeFromSkin(skin))
|
||||
}
|
||||
|
|
@ -667,6 +679,47 @@ export function createSessionStore() {
|
|||
)
|
||||
break
|
||||
}
|
||||
// ── gateway lifecycle / transport errors (auto-heal foundations) ──
|
||||
// The child exited mid-turn. THE key bug fix: clear the frozen `running`
|
||||
// spinner (no message.complete will ever arrive for the lost reply), tell
|
||||
// the user their in-flight reply was lost, and show a recovering status.
|
||||
case 'gateway.exited': {
|
||||
setState('info', prev => ({ ...prev, running: false }))
|
||||
setState('status', 'gateway exited — recovering…')
|
||||
const reason = event.payload?.reason
|
||||
const base = 'gateway exited — recovering your session (any in-flight reply was lost)'
|
||||
pushSystem(reason ? `${base}: ${reason}` : base)
|
||||
break
|
||||
}
|
||||
// A respawn+resume attempt is in flight — reflect the attempt in the status.
|
||||
case 'gateway.recovering': {
|
||||
const attempt = event.payload?.attempt
|
||||
setState('status', attempt ? `gateway recovering (attempt ${attempt})…` : 'gateway recovering…')
|
||||
break
|
||||
}
|
||||
// Collect stderr into a bounded ring (NOT the transcript) — see stderrRing.
|
||||
case 'gateway.stderr': {
|
||||
stderrRing.push(event.payload.line)
|
||||
if (stderrRing.length > STDERR_RING_LIMIT) stderrRing.splice(0, stderrRing.length - STDERR_RING_LIMIT)
|
||||
break
|
||||
}
|
||||
// The gateway never reached `gateway.ready` — surface the failure with any
|
||||
// stderr tail (payload is a loose Record; read defensively).
|
||||
case 'gateway.start_timeout': {
|
||||
const detail = readStr(event.payload, 'stderr') ?? readStr(event.payload, 'message') ?? stderrTail()
|
||||
pushSystem(detail ? `gateway failed to start:\n${detail}` : 'gateway failed to start')
|
||||
break
|
||||
}
|
||||
case 'gateway.protocol_error': {
|
||||
const preview = event.payload?.preview
|
||||
pushSystem(preview ? `gateway protocol error: ${preview}` : 'gateway protocol error')
|
||||
break
|
||||
}
|
||||
case 'error': {
|
||||
const message = event.payload?.message
|
||||
pushSystem(message ? `error: ${message}` : 'error')
|
||||
break
|
||||
}
|
||||
// Other event types (chrome) are reduced in later phases; unhandled members
|
||||
// are intentionally ignored here.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,36 @@ describe('GatewayEvent schema decode (Phase 1)', () => {
|
|||
).toBe(true)
|
||||
})
|
||||
|
||||
test('decodes gateway.exited with and without payload fields', () => {
|
||||
const full = decode({ type: 'gateway.exited', payload: { reason: 'SIGKILL', code: 137, signal: 'SIGKILL' } })
|
||||
expect(Option.isSome(full)).toBe(true)
|
||||
if (Option.isSome(full) && full.value.type === 'gateway.exited') {
|
||||
expect(full.value.payload?.reason).toBe('SIGKILL')
|
||||
expect(full.value.payload?.code).toBe(137)
|
||||
expect(full.value.payload?.signal).toBe('SIGKILL')
|
||||
}
|
||||
// payload is optional in full
|
||||
const bare = decode({ type: 'gateway.exited' })
|
||||
expect(Option.isSome(bare)).toBe(true)
|
||||
if (Option.isSome(bare) && bare.value.type === 'gateway.exited') {
|
||||
expect(bare.value.payload).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test('decodes gateway.recovering with and without payload fields', () => {
|
||||
const full = decode({ type: 'gateway.recovering', payload: { attempt: 2, delay_ms: 2000 } })
|
||||
expect(Option.isSome(full)).toBe(true)
|
||||
if (Option.isSome(full) && full.value.type === 'gateway.recovering') {
|
||||
expect(full.value.payload?.attempt).toBe(2)
|
||||
expect(full.value.payload?.delay_ms).toBe(2000)
|
||||
}
|
||||
const bare = decode({ type: 'gateway.recovering' })
|
||||
expect(Option.isSome(bare)).toBe(true)
|
||||
if (Option.isSome(bare) && bare.value.type === 'gateway.recovering') {
|
||||
expect(bare.value.payload).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test('SKIPS an unrecognized event type (Option.none, no throw)', () => {
|
||||
expect(Option.isNone(decode({ type: 'totally.unknown.event', foo: 1 }))).toBe(true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -331,6 +331,56 @@ describe('session store — session chrome / status bar (item 14)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('session store — gateway lifecycle / transport errors (auto-heal foundations)', () => {
|
||||
test('gateway.exited clears the frozen running spinner AND pushes a system notice', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'message.start' })
|
||||
expect(store.state.info.running).toBe(true) // a turn is in flight
|
||||
store.apply({ type: 'gateway.exited' })
|
||||
// THE key bug fix: the spinner is cleared even though no message.complete arrived.
|
||||
expect(store.state.info.running).toBe(false)
|
||||
expect(store.state.status).toBe('gateway exited — recovering…')
|
||||
const sys = store.state.messages.filter(m => m.role === 'system')
|
||||
expect(sys).toHaveLength(1)
|
||||
expect(sys[0]!.text).toContain('in-flight reply was lost')
|
||||
})
|
||||
|
||||
test('gateway.exited enriches the notice with payload.reason when present', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.exited', payload: { reason: 'SIGKILL', code: 137 } })
|
||||
const sys = store.state.messages.filter(m => m.role === 'system')
|
||||
expect(sys[0]!.text).toContain('SIGKILL')
|
||||
})
|
||||
|
||||
test('gateway.recovering reflects the attempt number in the status', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.recovering', payload: { attempt: 2 } })
|
||||
expect(store.state.status).toBe('gateway recovering (attempt 2)…')
|
||||
})
|
||||
|
||||
test('gateway.stderr is collected (NOT pushed to transcript), surfaced on start_timeout', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.stderr', payload: { line: 'ModuleNotFoundError: no module foo' } })
|
||||
store.apply({ type: 'gateway.stderr', payload: { line: 'traceback line 2' } })
|
||||
// chatty stderr never floods the transcript on its own
|
||||
expect(store.state.messages).toHaveLength(0)
|
||||
// …but the tail is surfaced when the gateway fails to start
|
||||
store.apply({ type: 'gateway.start_timeout', payload: {} })
|
||||
const sys = store.state.messages.filter(m => m.role === 'system')
|
||||
expect(sys).toHaveLength(1)
|
||||
expect(sys[0]!.text).toContain('gateway failed to start')
|
||||
expect(sys[0]!.text).toContain('ModuleNotFoundError')
|
||||
})
|
||||
|
||||
test('gateway.protocol_error and error are surfaced to the transcript', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.protocol_error', payload: { preview: '<garbled>' } })
|
||||
store.apply({ type: 'error', payload: { message: 'boom' } })
|
||||
const sys = store.state.messages.filter(m => m.role === 'system')
|
||||
expect(sys.map(m => m.text)).toEqual(['gateway protocol error: <garbled>', 'error: boom'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session store — resume hydrate (Phase 4b)', () => {
|
||||
test('beginBuffer + commitSnapshot replaces history then replays events buffered across the resume', () => {
|
||||
const store = createSessionStore()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue