fix(web): fence request telemetry lifecycles
This commit is contained in:
@@ -269,7 +269,6 @@ flowchart TD
|
|||||||
pkg_client_web_react --> pkg_invariants
|
pkg_client_web_react --> pkg_invariants
|
||||||
pkg_code_runtime --> pkg_invariants
|
pkg_code_runtime --> pkg_invariants
|
||||||
pkg_jsonrpc_demo --> pkg_invariants
|
pkg_jsonrpc_demo --> pkg_invariants
|
||||||
pkg_host_apiproxy --> pkg_invariants
|
|
||||||
pkg_host_directory_picker --> pkg_invariants
|
pkg_host_directory_picker --> pkg_invariants
|
||||||
pkg_host_webserver --> pkg_invariants
|
pkg_host_webserver --> pkg_invariants
|
||||||
pkg_storage --> pkg_invariants
|
pkg_storage --> pkg_invariants
|
||||||
@@ -587,6 +586,8 @@ flowchart TD
|
|||||||
pkg_client_ui_conversation --> pkg_client_ui_slots
|
pkg_client_ui_conversation --> pkg_client_ui_slots
|
||||||
pkg_client_ui_conversation --> pkg_invariants
|
pkg_client_ui_conversation --> pkg_invariants
|
||||||
pkg_client_ui_conversation --> pkg_token_meter
|
pkg_client_ui_conversation --> pkg_token_meter
|
||||||
|
pkg_host_apiproxy --> pkg_invariants
|
||||||
|
pkg_host_apiproxy --> pkg_token_meter
|
||||||
pkg_pty_local --> pkg_agent
|
pkg_pty_local --> pkg_agent
|
||||||
pkg_pty_local --> pkg_invariants
|
pkg_pty_local --> pkg_invariants
|
||||||
pkg_pty_local --> pkg_pty
|
pkg_pty_local --> pkg_pty
|
||||||
@@ -991,7 +992,6 @@ flowchart TD
|
|||||||
| [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) |
|
| [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) |
|
||||||
| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) |
|
| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) |
|
||||||
| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) |
|
| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) |
|
||||||
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) |
|
|
||||||
| [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) |
|
| [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) |
|
||||||
| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
|
| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
|
||||||
| [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
|
| [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
|
||||||
@@ -1078,6 +1078,7 @@ flowchart TD
|
|||||||
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
|
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
|
||||||
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) |
|
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) |
|
||||||
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) |
|
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) |
|
||||||
|
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants), [`token-meter`](../packages/llm/token-meter) |
|
||||||
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
|
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
|
||||||
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
|
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
|
||||||
| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
|
| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
|
||||||
|
|||||||
@@ -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
|
/** Frame sink callbacks: the Controller owns the physical streams; business dispatch belongs to
|
||||||
* SessionManager. */
|
* SessionManager. */
|
||||||
export interface ConnectionSinks {
|
export interface ConnectionSinks {
|
||||||
@@ -48,9 +44,6 @@ export interface ConnectionSinks {
|
|||||||
onConnected?: () => void
|
onConnected?: () => void
|
||||||
/** After every failed generation closes and before retry starts. Not emitted when the controller is stopped. */
|
/** After every failed generation closes and before retry starts. Not emitted when the controller is stopped. */
|
||||||
onDisconnected?: () => void
|
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 attempt = 0
|
||||||
private current: AbortController | null = null
|
private current: AbortController | null = null
|
||||||
private running = false
|
private running = false
|
||||||
private lastState: ConnectionState | null = null
|
|
||||||
private readonly config: Required<ConnectionConfig>
|
private readonly config: Required<ConnectionConfig>
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -140,7 +132,6 @@ export class ConnectionController {
|
|||||||
timeout.abort()
|
timeout.abort()
|
||||||
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
|
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
|
||||||
this.attempt = 0
|
this.attempt = 0
|
||||||
this.emitState('connected')
|
|
||||||
this.callSink(this.sinks.onConnected)
|
this.callSink(this.sinks.onConnected)
|
||||||
} catch {
|
} catch {
|
||||||
// Transport failure: treat as generation failure, fall through to the shared backoff.
|
// Transport failure: treat as generation failure, fall through to the shared backoff.
|
||||||
@@ -150,7 +141,6 @@ export class ConnectionController {
|
|||||||
await failed
|
await failed
|
||||||
if (!this.isRunning()) return
|
if (!this.isRunning()) return
|
||||||
this.callSink(this.sinks.onDisconnected)
|
this.callSink(this.sinks.onDisconnected)
|
||||||
this.emitState('reconnecting')
|
|
||||||
this.attempt += 1
|
this.attempt += 1
|
||||||
console.warn(`[web-runtime] connection lost, retry #${this.attempt}`)
|
console.warn(`[web-runtime] connection lost, retry #${this.attempt}`)
|
||||||
const idle = new AbortController()
|
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 }>(
|
private async pumpStream<F extends { type: string }>(
|
||||||
stream: AsyncIterable<RpcRequest<F>>,
|
stream: AsyncIterable<RpcRequest<F>>,
|
||||||
sink: ((envelope: RpcRequest<F>) => void) | undefined,
|
sink: ((envelope: RpcRequest<F>) => void) | undefined,
|
||||||
|
|||||||
@@ -1143,7 +1143,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
|||||||
// The fixture's durable transcript is historically zero-based, while
|
// The fixture's durable transcript is historically zero-based, while
|
||||||
// the real Agent's request telemetry opens turns at one.
|
// the real Agent's request telemetry opens turns at one.
|
||||||
turn: turn + 1,
|
turn: turn + 1,
|
||||||
step: 0,
|
step: 1,
|
||||||
provider: target.provider,
|
provider: target.provider,
|
||||||
model: target.model,
|
model: target.model,
|
||||||
// No fixture token-meter is composed, so omit the request-pressure
|
// No fixture token-meter is composed, so omit the request-pressure
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import type { IApiClient } from './api.ts'
|
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 { FixtureApiClient } from './fixture.ts'
|
||||||
import { WebApiClient } from './web-api-client.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
|
// Connection loop types are public through ConnectionHandle.start; the
|
||||||
// controller remains package-internal.
|
// controller remains package-internal.
|
||||||
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
|
export type { ConnectionConfig, ConnectionSinks }
|
||||||
|
|
||||||
|
|
||||||
/** Required services (none — this is the wire root). */
|
/** Required services (none — this is the wire root). */
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import type { IApiClient, SessionId } from '../src/client/api.ts'
|
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 { ConnectionController } from '../src/client/connection.ts'
|
||||||
import { FakeApiClient, deferred, ok } from './fake-api.ts'
|
import { FakeApiClient, deferred, ok } from './fake-api.ts'
|
||||||
|
|
||||||
@@ -204,29 +203,7 @@ describe('connection lifecycle', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('emits deduplicated connected/reconnecting state transitions', async () => {
|
it('reports every failed generation before retry', 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 () => {
|
|
||||||
const api = new FakeApiClient()
|
const api = new FakeApiClient()
|
||||||
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
|
const gate = deferred<Awaited<ReturnType<FakeApiClient['onDescribe']>>>()
|
||||||
let describeCalls = 0
|
let describeCalls = 0
|
||||||
@@ -234,14 +211,12 @@ describe('connection lifecycle', () => {
|
|||||||
describeCalls++
|
describeCalls++
|
||||||
return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise
|
return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise
|
||||||
}
|
}
|
||||||
const states: ConnectionState[] = []
|
|
||||||
let disconnected = 0
|
let disconnected = 0
|
||||||
let connected = 0
|
let connected = 0
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||||
const controller = new ConnectionController(api, {
|
const controller = new ConnectionController(api, {
|
||||||
onConnected: () => { connected++ },
|
onConnected: () => { connected++ },
|
||||||
onDisconnected: () => { disconnected++ },
|
onDisconnected: () => { disconnected++ },
|
||||||
onStateChange: state => states.push(state),
|
|
||||||
}, FAST)
|
}, FAST)
|
||||||
controller.start()
|
controller.start()
|
||||||
try {
|
try {
|
||||||
@@ -249,7 +224,6 @@ describe('connection lifecycle', () => {
|
|||||||
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
|
gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
|
||||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||||
expect(disconnected).toBe(2)
|
expect(disconnected).toBe(2)
|
||||||
expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
|
|
||||||
} finally {
|
} finally {
|
||||||
controller.stop()
|
controller.stop()
|
||||||
warnSpy.mockRestore()
|
warnSpy.mockRestore()
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ describe('createFixtureApi', () => {
|
|||||||
type: 'session/model-request',
|
type: 'session/model-request',
|
||||||
sessionId: id,
|
sessionId: id,
|
||||||
turn: 1,
|
turn: 1,
|
||||||
step: 0,
|
step: 1,
|
||||||
provider: 'deepseek',
|
provider: 'deepseek',
|
||||||
model: 'deepseek-v4-flash',
|
model: 'deepseek-v4-flash',
|
||||||
contextWindow: 128_000,
|
contextWindow: 128_000,
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||||
README.md: ba9a7d455e8a193f23884411eb1928a10f21ddd0
|
README.md: 03767fb5da46106a756207a0dda6353ae83aebc0
|
||||||
README.zh.md: 873fefca48585efed010589917f2c63653b08e5b
|
README.zh.md: fe495bb99c81e80695887e9c71b8ebdfe3f7fe78
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
English | [中文](README.zh.md)
|
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
|
## Workspace and Session lists
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[English](README.md) | 中文
|
[English](README.md) | 中文
|
||||||
|
|
||||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host 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 对象、列表/scope/history 状态;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host 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 列表
|
## Workspace 与 Session 列表
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ export class SessionManager {
|
|||||||
* cannot be backfilled when get() lazily creates the Session.
|
* cannot be backfilled when get() lazily creates the Session.
|
||||||
*/
|
*/
|
||||||
private readonly modelRequests = new Map<SessionId, ModelRequestTelemetry>()
|
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
|
/** 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
|
* 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
|
* 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 {
|
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
|
||||||
const frame = envelope.payload
|
const frame = envelope.payload
|
||||||
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
|
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') {
|
if (frame.type === 'session/projection') {
|
||||||
// Finished host-computed value: land it in the resident store whether or
|
// Finished host-computed value: land it in the resident store whether or
|
||||||
// not the Session is instantiated (list rows read the 'title' key). The
|
// not the Session is instantiated (list rows read the 'title' key). The
|
||||||
@@ -431,6 +438,7 @@ export class SessionManager {
|
|||||||
const frame = envelope.payload
|
const frame = envelope.payload
|
||||||
switch (frame.type) {
|
switch (frame.type) {
|
||||||
case 'host/session-added': {
|
case 'host/session-added': {
|
||||||
|
this.removedSessions.delete(frame.sessionId)
|
||||||
this.mergeSummary({
|
this.mergeSummary({
|
||||||
sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank,
|
sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank,
|
||||||
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
|
||||||
@@ -440,6 +448,7 @@ export class SessionManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case 'host/session-removed': {
|
case 'host/session-removed': {
|
||||||
|
this.removedSessions.add(frame.sessionId)
|
||||||
this.recordMutation({ kind: 'remove', sessionId: 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.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
|
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||||
|
|||||||
@@ -504,9 +504,24 @@ export class Session implements SessionFace {
|
|||||||
/** Connection-loss boundary: clear values that are not replayed before the next stream starts. */
|
/** Connection-loss boundary: clear values that are not replayed before the next stream starts. */
|
||||||
handleReconnecting(): void {
|
handleReconnecting(): void {
|
||||||
this.openGeneration++
|
this.openGeneration++
|
||||||
if (this.modelRequest === null) return
|
let changed = false
|
||||||
this.modelRequest = null
|
if (this.openState === 'loading') {
|
||||||
this.notifier.markDirty()
|
// 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. */
|
/** host/session-removed relay: flag the resident snapshot and clear request telemetry. */
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ describe('runtime client apply', () => {
|
|||||||
type: 'session/model-request',
|
type: 'session/model-request',
|
||||||
sessionId: 's-state',
|
sessionId: 's-state',
|
||||||
turn: 1,
|
turn: 1,
|
||||||
step: 0,
|
step: 1,
|
||||||
provider: 'test',
|
provider: 'test',
|
||||||
model: 'alpha',
|
model: 'alpha',
|
||||||
contextTokens: 32_000,
|
contextTokens: 32_000,
|
||||||
@@ -145,7 +145,7 @@ describe('runtime client apply', () => {
|
|||||||
type: 'session/model-request',
|
type: 'session/model-request',
|
||||||
sessionId: 's-state',
|
sessionId: 's-state',
|
||||||
turn: 2,
|
turn: 2,
|
||||||
step: 0,
|
step: 1,
|
||||||
provider: 'test',
|
provider: 'test',
|
||||||
model: 'beta',
|
model: 'beta',
|
||||||
contextTokens: 48_000,
|
contextTokens: 48_000,
|
||||||
|
|||||||
@@ -156,6 +156,19 @@ describe('instances', () => {
|
|||||||
payload: { type: 'host/session-removed', sessionId: S1 },
|
payload: { type: 'host/session-removed', sessionId: S1 },
|
||||||
})
|
})
|
||||||
expect(session.getSnapshot().modelRequest).toBeNull()
|
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({
|
manager.handleMuxEnvelope({
|
||||||
rpcId: 'request-before-lazy-removal' as never,
|
rpcId: 'request-before-lazy-removal' as never,
|
||||||
@@ -173,6 +186,18 @@ describe('instances', () => {
|
|||||||
rpcId: 'lazy-removed' as never,
|
rpcId: 'lazy-removed' as never,
|
||||||
payload: { type: 'host/session-removed', sessionId: S2 },
|
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()
|
expect(manager.get(S2).getSnapshot().modelRequest).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -822,6 +822,33 @@ describe('remaining branches', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('resync', () => {
|
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 () => {
|
it('clears request telemetry on reconnect and drops a stale in-flight history response', async () => {
|
||||||
const { api, session } = makeSession()
|
const { api, session } = makeSession()
|
||||||
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
const stale = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||||
|
|||||||
@@ -60,9 +60,15 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@deepseek-ai/dsh-token-meter": "^0.0.1",
|
||||||
"cordis": "^4.0.0-rc.7",
|
"cordis": "^4.0.0-rc.7",
|
||||||
"@deepseek-ai/dsh-invariants": "^0.0.1"
|
"@deepseek-ai/dsh-invariants": "^0.0.1"
|
||||||
},
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@deepseek-ai/dsh-token-meter": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
|
|||||||
type: z.literal('session/model-request'),
|
type: z.literal('session/model-request'),
|
||||||
sessionId: sessionIdSchema,
|
sessionId: sessionIdSchema,
|
||||||
turn: z.number().int().positive(),
|
turn: z.number().int().positive(),
|
||||||
step: z.number().int().nonnegative(),
|
step: z.number().int().positive(),
|
||||||
provider: z.string().min(1),
|
provider: z.string().min(1),
|
||||||
model: z.string().min(1),
|
model: z.string().min(1),
|
||||||
contextTokens: z.number().int().nonnegative().optional(),
|
contextTokens: z.number().int().nonnegative().optional(),
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ export type ToolEventView =
|
|||||||
|
|
||||||
/** Atomic telemetry captured at one observed model-request boundary. */
|
/** Atomic telemetry captured at one observed model-request boundary. */
|
||||||
export interface ModelRequestTelemetry {
|
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
|
turn: number
|
||||||
step: number
|
step: number
|
||||||
provider: string
|
provider: string
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import type {
|
|||||||
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
|
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
|
||||||
LlmResolvedModelInfo, StreamChunk,
|
LlmResolvedModelInfo, StreamChunk,
|
||||||
} from '@deepseek-ai/dsh-llm'
|
} 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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||||
@@ -51,7 +52,6 @@ class CatalogAdapter extends LlmAdapter {
|
|||||||
provider,
|
provider,
|
||||||
id: model,
|
id: model,
|
||||||
name: model,
|
name: model,
|
||||||
context: { contextWindow: model === 'private-preview' ? 128_000 : 64_000 },
|
|
||||||
...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
|
...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,15 @@ const REASONING: LlmModelReasoningInfo = {
|
|||||||
defaultEffort: ReasoningEffortId('high'),
|
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()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
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' },
|
||||||
{ provider: 'duplicate', id: 'same', name: 'Same Again' },
|
{ 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()
|
const session = ctx.sessions.create()
|
||||||
if (logged !== undefined) {
|
if (logged !== undefined) {
|
||||||
session.append('request/header', { header: { config: logged }, reason: 'initial' })
|
session.append('request/header', { header: { config: logged }, reason: 'initial' })
|
||||||
@@ -129,12 +124,7 @@ describe('Web session model selection', () => {
|
|||||||
model: 'private-preview',
|
model: 'private-preview',
|
||||||
reasoningEffort: ReasoningEffortId('max'),
|
reasoningEffort: ReasoningEffortId('max'),
|
||||||
})
|
})
|
||||||
const api = createApiProxy(ctx, {
|
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||||
provider: 'deepseek',
|
|
||||||
model: 'deepseek-chat',
|
|
||||||
cwd: '/tmp',
|
|
||||||
workspaceRoot: '/tmp',
|
|
||||||
})
|
|
||||||
|
|
||||||
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||||
expect(catalog.current).toEqual({
|
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 () => {
|
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 { ctx, agent, sessionId } = await harness()
|
||||||
const api = createApiProxy(ctx, {
|
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||||
provider: 'deepseek',
|
|
||||||
model: 'deepseek-chat',
|
|
||||||
cwd: '/tmp',
|
|
||||||
workspaceRoot: '/tmp',
|
|
||||||
})
|
|
||||||
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
|
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
|
||||||
const signal = new AbortController().signal
|
const signal = new AbortController().signal
|
||||||
|
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ describe('events frame schemas', () => {
|
|||||||
type: 'session/model-request',
|
type: 'session/model-request',
|
||||||
sessionId: 's',
|
sessionId: 's',
|
||||||
turn: 2,
|
turn: 2,
|
||||||
step: 0,
|
step: 1,
|
||||||
provider: 'deepseek',
|
provider: 'deepseek',
|
||||||
model: 'deepseek-chat',
|
model: 'deepseek-chat',
|
||||||
contextTokens: 8_000,
|
contextTokens: 8_000,
|
||||||
@@ -392,7 +392,7 @@ describe('events frame schemas', () => {
|
|||||||
expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow()
|
expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow()
|
||||||
for (const invalid of [
|
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: 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', contextTokens: -1 },
|
||||||
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 1, provider: 'p', model: 'm', contextWindow: 0 },
|
{ 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 },
|
{ type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 },
|
||||||
|
|||||||
Reference in New Issue
Block a user