fix(web): fence request telemetry lifecycles

This commit is contained in:
Hypatia May
2026-07-29 17:19:01 +08:00
parent 872cdfc774
commit 55b47657e5
19 changed files with 121 additions and 90 deletions

View File

@@ -35,10 +35,6 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
})
}
/** Coarse connection state for the UI (audit C1): 'connected' after each generation's handshake,
* 'reconnecting' the moment the generation fails (covers the whole backoff+retry span). */
export type ConnectionState = 'connected' | 'reconnecting'
/** Frame sink callbacks: the Controller owns the physical streams; business dispatch belongs to
* SessionManager. */
export interface ConnectionSinks {
@@ -48,9 +44,6 @@ export interface ConnectionSinks {
onConnected?: () => void
/** After every failed generation closes and before retry starts. Not emitted when the controller is stopped. */
onDisconnected?: () => void
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
* span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */
onStateChange?: (state: ConnectionState) => void
}
/**
@@ -65,7 +58,6 @@ export class ConnectionController {
private attempt = 0
private current: AbortController | null = null
private running = false
private lastState: ConnectionState | null = null
private readonly config: Required<ConnectionConfig>
constructor(
@@ -140,7 +132,6 @@ export class ConnectionController {
timeout.abort()
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
this.attempt = 0
this.emitState('connected')
this.callSink(this.sinks.onConnected)
} catch {
// Transport failure: treat as generation failure, fall through to the shared backoff.
@@ -150,7 +141,6 @@ export class ConnectionController {
await failed
if (!this.isRunning()) return
this.callSink(this.sinks.onDisconnected)
this.emitState('reconnecting')
this.attempt += 1
console.warn(`[web-runtime] connection lost, retry #${this.attempt}`)
const idle = new AbortController()
@@ -158,13 +148,6 @@ export class ConnectionController {
}
}
/** Deduplicated state emission (sink isolation applies). */
private emitState(state: ConnectionState): void {
if (this.lastState === state) return
this.lastState = state
this.callSink(() => this.sinks.onStateChange?.(state))
}
private async pumpStream<F extends { type: string }>(
stream: AsyncIterable<RpcRequest<F>>,
sink: ((envelope: RpcRequest<F>) => void) | undefined,

View File

@@ -1143,7 +1143,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
// The fixture's durable transcript is historically zero-based, while
// the real Agent's request telemetry opens turns at one.
turn: turn + 1,
step: 0,
step: 1,
provider: target.provider,
model: target.model,
// No fixture token-meter is composed, so omit the request-pressure

View File

@@ -5,7 +5,7 @@
*/
import type { Context } from 'cordis'
import type { IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts'
@@ -27,7 +27,7 @@ export { RpcId, AbstractApiClient, transportError } from './api.ts'
// Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal.
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
export type { ConnectionConfig, ConnectionSinks }
/** Required services (none — this is the wire root). */

View File

@@ -8,7 +8,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { IApiClient, SessionId } from '../src/client/api.ts'
import type { ConnectionState } from '../src/client/connection.ts'
import { ConnectionController } from '../src/client/connection.ts'
import { FakeApiClient, deferred, ok } from './fake-api.ts'
@@ -204,29 +203,7 @@ describe('connection lifecycle', () => {
}
})
it('emits deduplicated connected/reconnecting state transitions', async () => {
const api = new FakeApiClient()
const states: ConnectionState[] = []
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, {
onConnected: () => { connected++ },
onStateChange: state => states.push(state),
}, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(states).toEqual(['connected'])
api.failStreams(new Error('torn'))
await vi.waitFor(() => { expect(connected).toBe(2) })
expect(states).toEqual(['connected', 'reconnecting', 'connected'])
} finally {
controller.stop()
warnSpy.mockRestore()
}
})
it('reports every failed generation while deduplicating consecutive reconnecting state', async () => {
it('reports every failed generation before retry', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
let describeCalls = 0
@@ -234,14 +211,12 @@ describe('connection lifecycle', () => {
describeCalls++
return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise
}
const states: ConnectionState[] = []
let disconnected = 0
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, {
onConnected: () => { connected++ },
onDisconnected: () => { disconnected++ },
onStateChange: state => states.push(state),
}, FAST)
controller.start()
try {
@@ -249,7 +224,6 @@ describe('connection lifecycle', () => {
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(disconnected).toBe(2)
expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
} finally {
controller.stop()
warnSpy.mockRestore()

View File

@@ -198,7 +198,7 @@ describe('createFixtureApi', () => {
type: 'session/model-request',
sessionId: id,
turn: 1,
step: 0,
step: 1,
provider: 'deepseek',
model: 'deepseek-v4-flash',
contextWindow: 128_000,