From 6d2211d9d03cd343a5c05e6cdf0ff2862c53ee64 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 9 Jun 2026 07:39:31 +0000 Subject: [PATCH] opentui(harden): surface gateway exit/recovery + transport errors to the UI --- .../src/boundary/schema/GatewayEvent.ts | 17 +++++- ui-tui-opentui-v2/src/logic/store.ts | 53 +++++++++++++++++++ ui-tui-opentui-v2/src/test/schema.test.ts | 30 +++++++++++ ui-tui-opentui-v2/src/test/store.test.ts | 50 +++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) diff --git a/ui-tui-opentui-v2/src/boundary/schema/GatewayEvent.ts b/ui-tui-opentui-v2/src/boundary/schema/GatewayEvent.ts index 8a82e50a4ee..1c7d45558b3 100644 --- a/ui-tui-opentui-v2/src/boundary/schema/GatewayEvent.ts +++ b/ui-tui-opentui-v2/src/boundary/schema/GatewayEvent.ts @@ -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. */ diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index 8e3894ced84..b2801e46e13 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -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. } diff --git a/ui-tui-opentui-v2/src/test/schema.test.ts b/ui-tui-opentui-v2/src/test/schema.test.ts index b837020a70f..317667348fe 100644 --- a/ui-tui-opentui-v2/src/test/schema.test.ts +++ b/ui-tui-opentui-v2/src/test/schema.test.ts @@ -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) }) diff --git a/ui-tui-opentui-v2/src/test/store.test.ts b/ui-tui-opentui-v2/src/test/store.test.ts index fb504ddd747..3efba650619 100644 --- a/ui-tui-opentui-v2/src/test/store.test.ts +++ b/ui-tui-opentui-v2/src/test/store.test.ts @@ -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: '' } }) + 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: ', 'error: boom']) + }) +}) + describe('session store — resume hydrate (Phase 4b)', () => { test('beginBuffer + commitSnapshot replaces history then replays events buffered across the resume', () => { const store = createSessionStore()