opentui(harden): surface gateway exit/recovery + transport errors to the UI

This commit is contained in:
alt-glitch 2026-06-09 07:39:31 +00:00
parent c29402d731
commit 84b77f68e5
4 changed files with 149 additions and 1 deletions

View file

@ -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. */

View file

@ -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.
}

View file

@ -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)
})

View file

@ -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()