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,

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: ba9a7d455e8a193f23884411eb1928a10f21ddd0
README.zh.md: 873fefca48585efed010589917f2c63653b08e5b
README.md: 03767fb5da46106a756207a0dda6353ae83aebc0
README.zh.md: fe495bb99c81e80695887e9c71b8ebdfe3f7fe78

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`, `title`, and `tokenUsage`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. `ConversationSnapshot.modelRequest` separately retains the complete latest `session/model-request` observed on the current mux connection. Each frame replaces the whole snapshot, so omitted numerator or capacity fields clear an earlier value. `SessionManager` buffers one pre-instantiation snapshot, while `session/subscribed`, disconnect, and removal clear resident and pending values; reconnect, restore, and a new subscription therefore show no context percentage until another request is observed. Model selection alone does not alter request telemetry.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`, `title`, and `tokenUsage`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. `ConversationSnapshot.modelRequest` separately retains the complete latest `session/model-request` observed on the current mux connection. Each frame replaces the whole snapshot, so omitted numerator or capacity fields clear an earlier value. `SessionManager` buffers one pre-instantiation snapshot, while `session/subscribed`, disconnect, and removal clear resident and pending values; removal also installs a Host-lifecycle tombstone so a late frame from the independent mux stream cannot repopulate the removed session. Reconnect, restore, and a new subscription therefore show no context percentage until another request is observed. Model selection alone does not alter request telemetry.
## Workspace and Session lists

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos``title``tokenUsage`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot``ConversationSnapshot.modelRequest` 另行保留当前 mux 连接观察到的最新完整 `session/model-request`。每个帧都会替换整个快照,因此分子或容量字段一旦缺失,就会清除先前值。`SessionManager` 会缓冲一个实例化前快照;`session/subscribed`、断开连接和移除会话则会清除常驻值与待处理值;因此,重连、恢复和新订阅都不会显示上下文百分比,直到观察到另一次请求。仅选择模型不会改变请求观测数据。
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史尾页的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos``title``tokenUsage`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot``ConversationSnapshot.modelRequest` 另行保留当前 mux 连接观察到的最新完整 `session/model-request`。每个帧都会替换整个快照,因此分子或容量字段一旦缺失,就会清除先前值。`SessionManager` 会缓冲一个实例化前快照;`session/subscribed`、断开连接和移除会话则会清除常驻值与待处理值;移除还会安装 Host 生命周期删除标记,避免独立 mux 流中延迟到达的帧重新填充已移除的会话。因此,重连、恢复和新订阅都不会显示上下文百分比,直到观察到另一次请求。仅选择模型不会改变请求观测数据。
## Workspace 与 Session 列表

View File

@@ -66,6 +66,12 @@ export class SessionManager {
* cannot be backfilled when get() lazily creates the Session.
*/
private readonly modelRequests = new Map<SessionId, ModelRequestTelemetry>()
/**
* Host-lifecycle tombstones. Host and mux use independent SSE streams, so a
* frame emitted before removal can arrive after host/session-removed. Keep
* the id fenced until a later authoritative host/session-added.
*/
private readonly removedSessions = new Set<SessionId>()
/** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open
* replays of the same requested frame). Manager-owned rather than read off Session instances
* because the sidebar must light up for sessions never instantiated. Cleared per connection
@@ -348,6 +354,7 @@ export class SessionManager {
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
const frame = envelope.payload
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
if (this.removedSessions.has(frame.sessionId)) return
if (frame.type === 'session/projection') {
// Finished host-computed value: land it in the resident store whether or
// not the Session is instantiated (list rows read the 'title' key). The
@@ -431,6 +438,7 @@ export class SessionManager {
const frame = envelope.payload
switch (frame.type) {
case 'host/session-added': {
this.removedSessions.delete(frame.sessionId)
this.mergeSummary({
sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank,
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
@@ -440,6 +448,7 @@ export class SessionManager {
return
}
case 'host/session-removed': {
this.removedSessions.add(frame.sessionId)
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation

View File

@@ -504,9 +504,24 @@ export class Session implements SessionFace {
/** Connection-loss boundary: clear values that are not replayed before the next stream starts. */
handleReconnecting(): void {
this.openGeneration++
if (this.modelRequest === null) return
this.modelRequest = null
this.notifier.markDirty()
let changed = false
if (this.openState === 'loading') {
// The in-flight history request belongs to the dead generation. Its
// eventual success or failure is fenced below, so settle the visible
// pane now instead of leaving it loading throughout an outage.
this.openState = 'error'
this.openError = {
code: 'internal',
message: 'connection lost while loading session history',
details: { sessionId: this.sessionId },
}
changed = true
}
if (this.modelRequest !== null) {
this.modelRequest = null
changed = true
}
if (changed) this.notifier.markDirty()
}
/** host/session-removed relay: flag the resident snapshot and clear request telemetry. */

View File

@@ -118,7 +118,7 @@ describe('runtime client apply', () => {
type: 'session/model-request',
sessionId: 's-state',
turn: 1,
step: 0,
step: 1,
provider: 'test',
model: 'alpha',
contextTokens: 32_000,
@@ -145,7 +145,7 @@ describe('runtime client apply', () => {
type: 'session/model-request',
sessionId: 's-state',
turn: 2,
step: 0,
step: 1,
provider: 'test',
model: 'beta',
contextTokens: 48_000,

View File

@@ -156,6 +156,19 @@ describe('instances', () => {
payload: { type: 'host/session-removed', sessionId: S1 },
})
expect(session.getSnapshot().modelRequest).toBeNull()
manager.handleMuxEnvelope({
rpcId: 'late-resident-request' as never,
payload: {
type: 'session/model-request',
sessionId: S1,
turn: 2,
step: 1,
provider: 'test',
model: 'late',
contextWindow: 512_000,
},
})
expect(session.getSnapshot().modelRequest).toBeNull()
manager.handleMuxEnvelope({
rpcId: 'request-before-lazy-removal' as never,
@@ -173,6 +186,18 @@ describe('instances', () => {
rpcId: 'lazy-removed' as never,
payload: { type: 'host/session-removed', sessionId: S2 },
})
manager.handleMuxEnvelope({
rpcId: 'late-lazy-request' as never,
payload: {
type: 'session/model-request',
sessionId: S2,
turn: 2,
step: 1,
provider: 'test',
model: 'late-lazy',
contextWindow: 512_000,
},
})
expect(manager.get(S2).getSnapshot().modelRequest).toBeNull()
})

View File

@@ -822,6 +822,33 @@ describe('remaining branches', () => {
})
describe('resync', () => {
it('settles an in-flight open when its connection generation dies', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => stale.promise
const opening = session.open()
expect(session.getSnapshot().openState).toBe('loading')
session.handleReconnecting()
expect(session.getSnapshot()).toMatchObject({
openState: 'error',
openError: {
code: 'internal',
message: 'connection lost while loading session history',
},
})
stale.reject(new Error('dead generation failed'))
await opening
expect(session.getSnapshot()).toMatchObject({
openState: 'error',
openError: {
code: 'internal',
message: 'connection lost while loading session history',
},
})
})
it('clears request telemetry on reconnect and drops a stale in-flight history response', async () => {
const { api, session } = makeSession()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()

View File

@@ -60,9 +60,15 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@deepseek-ai/dsh-token-meter": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-invariants": "^0.0.1"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-token-meter": {
"optional": true
}
},
"devDependencies": {
"@deepseek-ai/dsh-storage": "workspace:^",
"@deepseek-ai/dsh-storage-domain": "workspace:^",

View File

@@ -41,7 +41,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
type: z.literal('session/model-request'),
sessionId: sessionIdSchema,
turn: z.number().int().positive(),
step: z.number().int().nonnegative(),
step: z.number().int().positive(),
provider: z.string().min(1),
model: z.string().min(1),
contextTokens: z.number().int().nonnegative().optional(),

View File

@@ -33,6 +33,12 @@ export type ToolEventView =
/** Atomic telemetry captured at one observed model-request boundary. */
export interface ModelRequestTelemetry {
/**
* Agent request identity and resolved route. The current StatsLine consumes
* only occupancy, while the complete snapshot preserves provenance for
* diagnostics and later request-bound consumers without consulting mutable
* selected-model state.
*/
turn: number
step: number
provider: string

View File

@@ -13,7 +13,8 @@ import type {
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
LlmResolvedModelInfo, StreamChunk,
} from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -51,7 +52,6 @@ class CatalogAdapter extends LlmAdapter {
provider,
id: model,
name: model,
context: { contextWindow: model === 'private-preview' ? 128_000 : 64_000 },
...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
})
}
@@ -70,7 +70,15 @@ const REASONING: LlmModelReasoningInfo = {
defaultEffort: ReasoningEffortId('high'),
}
async function hostContext(): Promise<Context> {
async function harness(logged?: {
provider: string
model: string
reasoningEffort?: ReasoningEffortId
}): Promise<{
ctx: Context
agent: Agent
sessionId: SessionId
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
@@ -90,19 +98,6 @@ async function hostContext(): Promise<Context> {
{ provider: 'duplicate', id: 'same', name: 'Same' },
{ provider: 'duplicate', id: 'same', name: 'Same Again' },
]))
return ctx
}
async function harness(logged?: {
provider: string
model: string
reasoningEffort?: ReasoningEffortId
}): Promise<{
ctx: Context
agent: Agent
sessionId: SessionId
}> {
const ctx = await hostContext()
const session = ctx.sessions.create()
if (logged !== undefined) {
session.append('request/header', { header: { config: logged }, reason: 'initial' })
@@ -129,12 +124,7 @@ describe('Web session model selection', () => {
model: 'private-preview',
reasoningEffort: ReasoningEffortId('max'),
})
const api = createApiProxy(ctx, {
provider: 'deepseek',
model: 'deepseek-chat',
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
expect(catalog.current).toEqual({
@@ -175,12 +165,7 @@ describe('Web session model selection', () => {
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
const { ctx, agent, sessionId } = await harness()
const api = createApiProxy(ctx, {
provider: 'deepseek',
model: 'deepseek-chat',
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal

View File

@@ -365,7 +365,7 @@ describe('events frame schemas', () => {
type: 'session/model-request',
sessionId: 's',
turn: 2,
step: 0,
step: 1,
provider: 'deepseek',
model: 'deepseek-chat',
contextTokens: 8_000,
@@ -392,7 +392,7 @@ describe('events frame schemas', () => {
expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow()
for (const invalid of [
{ type: 'session/model-request', sessionId: 's', turn: 0, step: 1, provider: 'p', model: 'm' },
{ type: 'session/model-request', sessionId: 's', turn: 1, step: -1, provider: 'p', model: 'm' },
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 0, provider: 'p', model: 'm' },
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 1, provider: 'p', model: 'm', contextTokens: -1 },
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 1, provider: 'p', model: 'm', contextWindow: 0 },
{ type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 },