opentui(harden): auto-heal — restart + resume on gateway crash
This commit is contained in:
parent
41a5bbf3e8
commit
07fcb3282c
@ -74,6 +74,9 @@ export class RawGatewayClient {
|
|||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
onExit: (_p, code, signal) => {
|
onExit: (_p, code, signal) => {
|
||||||
|
// Identity guard: a stale child's late exit must not act after a restart
|
||||||
|
// has already installed a new `this.proc` (else it'd null the live child).
|
||||||
|
if (this.proc !== proc) return
|
||||||
const reason = `gateway exited (code=${code ?? 'null'} signal=${signal ?? 'null'})`
|
const reason = `gateway exited (code=${code ?? 'null'} signal=${signal ?? 'null'})`
|
||||||
this.log.warn('gateway', reason)
|
this.log.warn('gateway', reason)
|
||||||
this.rejectAll(reason)
|
this.rejectAll(reason)
|
||||||
@ -171,7 +174,9 @@ export class RawGatewayClient {
|
|||||||
|
|
||||||
/** Send a JSON-RPC request; resolves with `result` (long handlers reply async). */
|
/** Send a JSON-RPC request; resolves with `result` (long handlers reply async). */
|
||||||
request<A = unknown>(method: string, params: unknown): Promise<A> {
|
request<A = unknown>(method: string, params: unknown): Promise<A> {
|
||||||
if (!this.proc) this.start()
|
// Do NOT auto-start here: during the recovery backoff window `this.proc` is
|
||||||
|
// null, and a respawn here would BYPASS the backoff (the first spawn always
|
||||||
|
// comes from subscribe() → client.start()). A null proc rejects below.
|
||||||
const proc = this.proc
|
const proc = this.proc
|
||||||
const stdin = proc?.stdin
|
const stdin = proc?.stdin
|
||||||
if (!stdin || typeof stdin === 'number') return Promise.reject(new Error('gateway not running'))
|
if (!stdin || typeof stdin === 'number') return Promise.reject(new Error('gateway not running'))
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
import { Effect, Layer, Option, Schema } from 'effect'
|
import { Effect, Layer, Option, Schema } from 'effect'
|
||||||
import { batch } from 'solid-js'
|
import { batch } from 'solid-js'
|
||||||
|
|
||||||
|
import { backoffMs, planGatewayRecovery } from '../../logic/gatewayRecovery.ts'
|
||||||
import { GatewayError } from '../errors.ts'
|
import { GatewayError } from '../errors.ts'
|
||||||
import { getLog } from '../log.ts'
|
import { getLog } from '../log.ts'
|
||||||
import { GatewayEventSchema, type GatewayEvent } from '../schema/GatewayEvent.ts'
|
import { GatewayEventSchema, type GatewayEvent } from '../schema/GatewayEvent.ts'
|
||||||
@ -30,6 +31,14 @@ function makeLiveGateway(): { service: GatewayServiceShape; stop: () => void } {
|
|||||||
const handlers = new Set<(event: GatewayEvent) => void>()
|
const handlers = new Set<(event: GatewayEvent) => void>()
|
||||||
let sessionId: string | undefined
|
let sessionId: string | undefined
|
||||||
|
|
||||||
|
// Auto-heal recovery state (driver below). `recoverSid` is the resume target
|
||||||
|
// carried across a respawn that died before gateway.ready; `recoveryAttempts`
|
||||||
|
// is the sliding crash-loop budget window; `restartTimer` is the pending
|
||||||
|
// backoff respawn (cleared on teardown so it can't fire post-stop).
|
||||||
|
let recoverSid: string | undefined
|
||||||
|
let recoveryAttempts: number[] = []
|
||||||
|
let restartTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
// 16ms event coalescing → one batched repaint (opencode sdk.tsx model).
|
// 16ms event coalescing → one batched repaint (opencode sdk.tsx model).
|
||||||
let queue: GatewayEvent[] = []
|
let queue: GatewayEvent[] = []
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
@ -69,10 +78,37 @@ function makeLiveGateway(): { service: GatewayServiceShape; stop: () => void } {
|
|||||||
enqueue(decoded.value)
|
enqueue(decoded.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recovery driver: on a child exit, clear the frozen spinner (via the store's
|
||||||
|
// gateway.exited case), then — under the crash-loop budget — respawn the child
|
||||||
|
// on exponential backoff. The post-respawn gateway.ready triggers the re-resume
|
||||||
|
// (driven from entry's subscribe callback). Hoisted so it can be passed to
|
||||||
|
// `new RawGatewayClient` below while itself referencing the `client` const —
|
||||||
|
// `client` is assigned by the time onExit ever fires at runtime.
|
||||||
|
function onExit(reason: string): void {
|
||||||
|
log.warn('gateway', 'transport exited', { reason })
|
||||||
|
// Clears the frozen spinner + shows status (store handles gateway.exited).
|
||||||
|
enqueue({ type: 'gateway.exited', payload: { reason } })
|
||||||
|
const plan = planGatewayRecovery(sessionId ?? null, recoverSid ?? null, recoveryAttempts, Date.now())
|
||||||
|
recoveryAttempts = plan.attempts
|
||||||
|
if (!plan.recover || plan.sid === null) {
|
||||||
|
enqueue({ type: 'error', payload: { message: 'gateway exited repeatedly — type /resume to retry' } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recoverSid = plan.sid
|
||||||
|
const attempt = recoveryAttempts.length
|
||||||
|
const delay = backoffMs(attempt)
|
||||||
|
enqueue({ type: 'gateway.recovering', payload: { attempt, delay_ms: delay } })
|
||||||
|
if (restartTimer) clearTimeout(restartTimer)
|
||||||
|
restartTimer = setTimeout(() => {
|
||||||
|
restartTimer = undefined
|
||||||
|
client.start()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
const client = new RawGatewayClient({
|
const client = new RawGatewayClient({
|
||||||
log,
|
log,
|
||||||
onEvent: onRawEvent,
|
onEvent: onRawEvent,
|
||||||
onExit: reason => log.warn('gateway', 'transport exited', { reason })
|
onExit
|
||||||
})
|
})
|
||||||
|
|
||||||
const service: GatewayServiceShape = {
|
const service: GatewayServiceShape = {
|
||||||
@ -118,6 +154,9 @@ function makeLiveGateway(): { service: GatewayServiceShape; stop: () => void } {
|
|||||||
const stop = () => {
|
const stop = () => {
|
||||||
if (timer) clearTimeout(timer)
|
if (timer) clearTimeout(timer)
|
||||||
timer = undefined
|
timer = undefined
|
||||||
|
// Also kill any pending backoff respawn so it can't fire after teardown.
|
||||||
|
if (restartTimer) clearTimeout(restartTimer)
|
||||||
|
restartTimer = undefined
|
||||||
client.stop()
|
client.stop()
|
||||||
}
|
}
|
||||||
return { service, stop }
|
return { service, stop }
|
||||||
|
|||||||
@ -194,8 +194,28 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||||||
const pasteStore = createPasteStore()
|
const pasteStore = createPasteStore()
|
||||||
|
|
||||||
// Contact point #2: boundary pushes decoded events into the Solid store.
|
// Contact point #2: boundary pushes decoded events into the Solid store.
|
||||||
|
// The callback ALSO drives auto-heal re-resume: a post-crash gateway.ready
|
||||||
|
// (i.e. one that follows a gateway.exited, so `recoverSid` is set) re-resumes
|
||||||
|
// the session so the transcript continues. The INITIAL gateway.ready has
|
||||||
|
// `recoverSid === undefined`, so the normal bootstrap path is untouched.
|
||||||
const gateway = yield* GatewayService
|
const gateway = yield* GatewayService
|
||||||
yield* gateway.subscribe(event => store.apply(event))
|
let recoverSid: string | undefined
|
||||||
|
yield* gateway.subscribe(event => {
|
||||||
|
store.apply(event)
|
||||||
|
if (event.type === 'gateway.exited') {
|
||||||
|
recoverSid = gateway.sessionId() ?? recoverSid
|
||||||
|
} else if (event.type === 'gateway.ready' && recoverSid !== undefined) {
|
||||||
|
const sid = recoverSid
|
||||||
|
recoverSid = undefined
|
||||||
|
Effect.runFork(
|
||||||
|
resumeInto(gateway, store, sid, input.cols).pipe(
|
||||||
|
Effect.catchCause(cause =>
|
||||||
|
Effect.sync(() => getLog().warn('recover', 'resume failed', { cause: String(cause) }))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ── Ctrl+C state machine (item 11) ──────────────────────────────────
|
// ── Ctrl+C state machine (item 11) ──────────────────────────────────
|
||||||
// While a turn runs, the first Ctrl+C STOPS the agent (session.interrupt);
|
// While a turn runs, the first Ctrl+C STOPS the agent (session.interrupt);
|
||||||
|
|||||||
@ -685,7 +685,10 @@ export function createSessionStore() {
|
|||||||
// the user their in-flight reply was lost, and show a recovering status.
|
// the user their in-flight reply was lost, and show a recovering status.
|
||||||
case 'gateway.exited': {
|
case 'gateway.exited': {
|
||||||
setState('info', prev => ({ ...prev, running: false }))
|
setState('info', prev => ({ ...prev, running: false }))
|
||||||
setState('status', 'gateway exited — recovering…')
|
// Neutral status: we don't ALWAYS recover (budget exhaustion). The
|
||||||
|
// "recovering…" wording now comes from the gateway.recovering case,
|
||||||
|
// which fires only when a respawn is actually scheduled.
|
||||||
|
setState('status', 'gateway exited')
|
||||||
const reason = event.payload?.reason
|
const reason = event.payload?.reason
|
||||||
const base = 'gateway exited — recovering your session (any in-flight reply was lost)'
|
const base = 'gateway exited — recovering your session (any in-flight reply was lost)'
|
||||||
pushSystem(reason ? `${base}: ${reason}` : base)
|
pushSystem(reason ? `${base}: ${reason}` : base)
|
||||||
|
|||||||
@ -339,7 +339,8 @@ describe('session store — gateway lifecycle / transport errors (auto-heal foun
|
|||||||
store.apply({ type: 'gateway.exited' })
|
store.apply({ type: 'gateway.exited' })
|
||||||
// THE key bug fix: the spinner is cleared even though no message.complete arrived.
|
// THE key bug fix: the spinner is cleared even though no message.complete arrived.
|
||||||
expect(store.state.info.running).toBe(false)
|
expect(store.state.info.running).toBe(false)
|
||||||
expect(store.state.status).toBe('gateway exited — recovering…')
|
// Neutral status — "recovering…" now comes from gateway.recovering only.
|
||||||
|
expect(store.state.status).toBe('gateway exited')
|
||||||
const sys = store.state.messages.filter(m => m.role === 'system')
|
const sys = store.state.messages.filter(m => m.role === 'system')
|
||||||
expect(sys).toHaveLength(1)
|
expect(sys).toHaveLength(1)
|
||||||
expect(sys[0]!.text).toContain('in-flight reply was lost')
|
expect(sys[0]!.text).toContain('in-flight reply was lost')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user