Back to Qwen Code

Typed Daemon Event Schema v1

docs/developers/daemon/09-event-schema.md

0.18.131.3 KB
Original Source

Typed Daemon Event Schema v1

概览

daemon 在 GET /session/:id/events 上发的每一帧 SSE 都形如 { id, v, type, data, originatorClientId?, _meta? }v: 1 是当前 EVENT_SCHEMA_VERSIONtype 取自一个封闭的、版本固定的集合 —— DAEMON_KNOWN_EVENT_TYPE_VALUESpackages/sdk-typescript/src/daemon/events.ts)共 43 种。envelope 的 _meta 字段在 SSE 写边界(server.tsformatSseFrame())盖上 —— 详见下文 Envelope 级元数据

SDK 暴露 asKnownDaemonEvent(evt),对已知 type 返回一个判别式 KnownDaemonEvent,对其他 type 返回 undefined —— SDK 消费方无需固定 SDK 版本就能处理向前兼容(更新的 daemon 加了新 type 也不会崩,会计入 unrecognizedKnownEventCount)。

wire 格式见 ../qwen-serve-protocol.md,本文是每个事件的 payload 契约。

职责

  • 提供事件词汇表的唯一事实来源(DAEMON_KNOWN_EVENT_TYPE_VALUES)。
  • 提供每种 type 的 typed envelope(DaemonEventEnvelope<TType, TData>)。
  • 提供纯 reducer(reduceDaemonSessionEventreduceDaemonAuthEvent),把事件流投影成 SDK view-state。
  • 通过 typed_event_schema 能力 tag 广播(信息性 —— 不广播时 asKnownDaemonEvent 仍 fallback 到 unknown)。

事件词汇表(43 种已知 type)

按域分组。

Core session

Type方向触发Payload 关键字段
session_updateS→C任意 ACP sessionUpdate 通知(agent text / thought / tool call / plan)sessionUpdate: string, content?: ...(不透明 ACP shape)
session_metadata_updatedS→CPATCH /session/:id/metadatasessionId, displayName?
session_diedS→C 终态channel.exited 触发sessionId, reason, exitCode? | null, signalCode? | null
session_closedS→C 终态DELETE /session/:id 或程序化关闭sessionId, reason: 'client_close' | string, closedBy?
session_snapshotS→C 合成SSE attach / replay 后的快照帧sessionId, currentModelId: string | null, currentApprovalMode: string | null

Subscriber 级合成帧

Type触发备注
client_evictedEventBus 每订阅者队列溢出。idreason: string, droppedAfter?: number;只对当前订阅者终态,session 还活着
slow_client_warning队列 ≥ 75%(force-push,idqueueSize, maxQueued, lastEventId;37.5% 滞回 re-arm
stream_errorSubscriberLimitExceededError 或其他路由流错error: string;订阅终态
state_resync_requiredsubscribe({lastEventId}) 时 daemon 环里已不再持有 [lastEventId+1, earliestInRing-1] 这段间隙,或客户端游标来自上一轮 bus epoch。在剩余 replay 帧之前强推。idreason: 'ring_evicted' | 'epoch_reset' | stringlastDeliveredId: numberearliestAvailableId: numberring_evicted 表示同一 epoch 的 ring 缺口;epoch_reset 表示 daemon / EventBus 重建后客户端带了旧 epoch 的高水位。面向恢复,非终态 —— SSE 流保持打开,replay + live 帧继续;SDK reducer 翻转 awaitingResync = true,自动跳过 delta,直到调用方调 loadSession 重置。daemon 端实现见 packages/acp-bridge/src/eventBus.ts,SDK 端见 packages/sdk-typescript/src/daemon/events.ts
replay_completeLast-Event-ID 重放循环结束时强推的 id-less 哨兵;clean-replay 与 ring-evicted(state_resync_required)两条路径都发,即使无帧可重放(data.replayedCount === 0)也发。idreplayedCount: number;消费方据此确定性地撤掉 catch-up 指示,不必靠超时

Permissions(F3 + base)

Type方向触发Payload 关键字段
permission_requestS→Cagent 调 requestPermissionrequestId, sessionId, toolCall, options[];envelope 盖 originatorClientId(= prompt originator,F3 N3)
permission_resolvedS→Cmediator 已裁决requestId, outcome(ACP PermissionOutcome
permission_already_resolvedS→C已裁决后投票才到requestId, sessionId, outcome
permission_partial_voteS→Cconsensus 策略记录了一次不裁决的投票requestId, sessionId, votesReceived, votesNeeded (≥1), quorum, optionTallies: Record<string, number>, originatorClientId?
permission_forbiddenS→C投票被策略拒绝requestId, sessionId, clientId?, reason: 'designated_mismatch' | 'remote_not_allowed', originatorClientId?;匿名投票者无 clientId

Models

Type方向Payload
model_switchedS→CsessionId, modelId
model_switch_failedS→CsessionId, requestedModelId, error: string

MCP guardrails(PR 14b + F2)

Type方向Payload
mcp_budget_warningS→CliveCount, reservedCount, budget, thresholdRatio: 0.75, mode: 'warn' | 'enforce', scope?: 'workspace' | 'session'
mcp_child_refused_batchS→CrefusedServers: [{name, transport, reason: 'budget_exhausted'}], budget, liveCount, reservedCount, mode: 'enforce', scope?: 'workspace' | 'session'
mcp_server_restartedS→CserverName, durationMs, entryIndex?(F2 多 entry)
mcp_server_restart_refusedS→CserverName, reason: 'budget_would_exceed' | 'in_flight' | 'disabled' | 'restart_failed', entryIndex?, details?。第 4 个值 'restart_failed'(F2 commit 5)携带底层硬失败,details 是自由格式字符串,给池模式多 entry restart 用。封闭集判别MCP_RESTART_REFUSED_REASONS 拒绝未知 reason,老 SDK reducer 看到加法新值会默默丢弃事件(parseDaemonEvent 返回 undefined)。新 reason 值必须与认识它的 SDK 版本一起发

Mutation control(Wave 4 PR 16+17)

Type方向Payload
memory_changedS→Cscope: 'workspace' | 'global', filePath, mode: 'append' | 'replace', bytesWritten
agent_changedS→Cchange: 'created' | 'updated' | 'deleted', name, level: 'project' | 'user'
approval_mode_changedS→CsessionId, previous, next, persisted: boolean
tool_toggledS→CtoolName, enabled(下次 ACP child spawn 才生效,不会回溯改动已在跑的 session)
settings_changedS→Cworkspace settings 写入完成;payload 是开放对象,消费方用 read-after-write 刷新
settings_reloadedS→Cdaemon workspace service 重新读取 settings;payload 是开放对象
workspace_initializedS→Cpath, action: 'created' | 'overwrote' | 'noop', originatorClientId?

Auth device flow(PR 21)

这些是 workspace-keyed 不是 session-keyed。session reducer 对它们 no-op;reduceDaemonAuthEvent 投到 workspace-level state。

Type方向Payload
auth_device_flow_startedS→CdeviceFlowId, providerId, expiresAt
auth_device_flow_throttledS→CdeviceFlowId, intervalMs
auth_device_flow_authorizedS→CdeviceFlowId, providerId, expiresAt?, accountAlias?
auth_device_flow_failedS→CdeviceFlowId, errorKind, hint?
auth_device_flow_cancelledS→CdeviceFlowId

MCP runtime mutation(运行时增删 server)

Type方向触发Payload 关键字段
mcp_server_addedS→C运行时经 POST /workspace/mcp/servers 新增 servername, transport, replaced, shadowedSettings, toolCount, originatorClientId
mcp_server_removedS→C运行时移除 servername, wasShadowingSettings, originatorClientId

Turn 生命周期 / 助手推送(assist)

Type方向触发Payload 关键字段
prompt_cancelledS→Cprompt 被取消(显式 cancelSession 路由 originator SSE 断开)envelope 盖 originatorClientId(取消方);语义是「请求取消」而非「确认取消」。多客户端 session 中,peer 订阅者据此知道 prompt 已终止
turn_completeS→C一个 turn 正常结束sessionId, stopReason, promptId?promptId 与 non-blocking prompt(202 响应)关联——SDK 通过匹配 promptId 将 SSE 事件与发起的 prompt 绑定
turn_errorS→Cturn 出错sessionId, message, code?, promptId?。同上 promptId 关联机制
session_rewoundS→CPOST /session/:id/rewind 成功回滚sessionId, promptId, targetTurnIndex, filesChanged[], filesFailed[], originatorClientId?
session_branchedS→CPOST /session/:id/branch 从既有 session 分支sourceSessionId, newSessionId, displayName, originatorClientId?
followup_suggestionS→Cend_turn 后 ACP child 生成的 ghost-text 后续建议,经 per-session SSE 转发sessionId, suggestion, promptId(wire 只带 getFilterReason()===null 的建议)。客户端渲染为输入占位符 ghost-text,下次 sendPrompt 时自行失效
user_shell_commandS→C用户通过 POST /session/:id/shell 发起的 shell 命令,扇出给同 session 其他订阅者sessionId, command, shellId, originatorClientId?无 typed DaemonXxxData 接口——asKnownDaemonEvent 返回 undefined,由 normalizer 层 ad-hoc 解析
user_shell_resultS→C上述 shell 命令的执行结果sessionId, shellId, exitCode, output, aborted。同上,无 typed 接口

架构

关注点说明
EVENT_SCHEMA_VERSION = 1packages/acp-bridge/src/eventBus.ts每帧带
DAEMON_KNOWN_EVENT_TYPE_VALUESpackages/sdk-typescript/src/daemon/events.ts封闭列表(43 种)
DaemonEventEnvelope<TType, TData>events.ts泛型 envelope
DaemonKnownEventTypeevents.tstypeof DAEMON_KNOWN_EVENT_TYPE_VALUES[number]
各事件 payload 类型events.ts多数 type 有 DaemonXxxData interface;user_shell_* 当前由 UI normalizer ad-hoc 解析
asKnownDaemonEvent(evt)events.ts返回 KnownDaemonEvent | undefined
reduceDaemonSessionEvent(state, evt)events.ts投到 DaemonSessionViewState
reduceDaemonAuthEvent(state, evt)events.ts投到 DaemonAuthState
isWorkspaceScopedBudgetEvent(evt)events.ts判别 F2 scope: 'workspace'

DaemonSessionViewState

reduceDaemonSessionEvent 填充,CLI TUI adapter、DaemonChannelBridge、VSCode IDE 都消费。关键字段:

  • alive: boolean — 一旦观察到终态帧(session_died / session_closed / client_evicted / stream_error)变 false
  • currentModelId?: string — 由 model_switched
  • displayName?: string — 由 session_metadata_updated
  • pendingPermissions: Record<string, DaemonPermissionRequestData> — 当前打开的请求,按 requestId 索引;permission_resolved / permission_already_resolved 时清掉。
  • lastSessionUpdate?: DaemonSessionUpdateData — 最近的 session_update
  • lastModelSwitchFailure?: DaemonModelSwitchFailedData — 由 model_switch_failed
  • terminalEvent? — 终态帧原始事件。
  • streamError?: DaemonStreamErrorData — 最近的 stream_error payload。
  • unrecognizedKnownEventCountlastUnrecognizedKnownEvent?asKnownDaemonEvent 识别但 reducer 尚未建专用状态的事件。
  • droppedPermissionRequestCountlastDroppedPermissionRequestId? — 结构不合法、无法进入 pending map 的权限请求。
  • unmatchedPermissionResolutionCountlastUnmatchedPermissionResolutionId? — 没有匹配 pending request 的权限 resolution。
  • slowClientWarningCountlastSlowClientWarning? — 由 slow_client_warning
  • mcpBudgetWarningCountlastMcpBudgetWarning? — 由 mcp_budget_warning
  • mcpChildRefusedBatchCountlastMcpChildRefusedBatch? — 由 mcp_child_refused_batch
  • lastWorkspaceMutation?lastWorkspaceMutationType? — 由 memory_changed / agent_changed
  • approvalMode?approvalModeChangedCountlastApprovalModeChange? — 由 approval_mode_changed
  • toolToggleCountlastToolToggle? — 由 tool_toggled
  • workspaceInitCountlastWorkspaceInit? — 由 workspace_initialized
  • mcpRestartCountlastMcpRestart? — 由 mcp_server_restarted
  • mcpRestartRefusedCountlastMcpRestartRefused? — 由 mcp_server_restart_refused
  • settings_changed / settings_reloadedasKnownDaemonEvent 识别,session reducer 当前不维护专用 view-state 字段;UI 一般把它们当作刷新 workspace settings 的信号。
  • permissionVoteProgress: Record<string, DaemonPermissionPartialVoteData> — consensus 投票进度(F3)。
  • forbiddenVotes: DaemonPermissionForbiddenData[]forbiddenVoteCount — 被策略拒绝的投票记录(F3,上限 32)。
  • awaitingResync: booleanstate_resync_required 时置 true;消费方重置 view-state 时清。
  • resyncRequiredCountlastResyncRequired? — resync 观测计数。
  • lastFollowupSuggestion?: DaemonFollowupSuggestionData — daemon 推送的后续建议。
  • lastTurnComplete?: DaemonTurnCompleteData — 最近的 turn 正常结束。
  • lastTurnError?: DaemonTurnErrorData — 最近的 turn 错误。
  • rewindCountlastRewind?lastBranch? — 最近的 rewind / branch 事件。

DaemonAuthState

providerId 一项,由 auth_device_flow_* 驱动。每个 flow 暴露 {deviceFlowId, status, providerId, expiresAt?, lastThrottleIntervalMs?, lastError?}

流程

Producer 端

mermaid
flowchart LR
    A["ACP child notification"] --> B["BridgeClient.sessionUpdate /
BridgeClient.extNotification"]
    B --> C{"Mapped to event type?"}
    C -->|yes| D["EventBus.publish({type, data, originatorClientId?})"]
    C -->|no| E["No emit (drop or log)"]
    D --> F["Assigns id + v=1, pushes to ring"]
    F --> G["Fans to all subscribers"]

Consumer 端(SDK)

mermaid
flowchart LR
    A["SSE bytes"] --> B["parseSseStream → DaemonEvent[]"]
    B --> C["asKnownDaemonEvent(evt)"]
    C -->|"KnownDaemonEvent"| D["reduceDaemonSessionEvent(state, evt)"]
    C -->|"auth_device_flow_*"| E["reduceDaemonAuthEvent(state, evt)"]
    C -->|"undefined"| F["unrecognizedKnownEventCount++ (forward-compat)"]

Envelope 级元数据

除了每事件的 data payload,daemon 还在 envelope 上盖两个字段:

_meta.serverTimestamp —— daemon 时钟

formatSseFrame()packages/cli/src/serve/server.ts)的 SSE 写边界盖,EventBus.publish。这样内存里的 BridgeEvent 类型不变,内部 daemon 消费方看不到 _meta,只有 wire 上的 SSE 帧带。

jsonc
// 盖完之后 wire 上的一帧
{
  "id": 47,
  "v": 1,
  "type": "session_update",
  "data": { ... },
  "_meta": { "serverTimestamp": 1716287345123 }
}

merge 保留任何已有 _meta 键({...existingMeta, serverTimestamp: Date.now()})。当前 daemon 没有任何生产者写 envelope 级 _meta —— wenshao #4360 review 已确认 ToolCallEmitter 的元数据嵌在 event.data._meta(ACP session/update payload 自己的 _meta),不是 envelope。顶层 merge 是向前兼容逃生口。

为什么重要:多客户端 UI 渲「X 分钟前」或按 emit 时间排序 transcript 块时,老路径用各自本地时钟,跨浏览器 / 标签 / 手机漂几十秒到几分钟。服务端盖戳之后,所有客户端排序一致。

SDK 访问:优先读 envelope 级 event._meta?.serverTimestamp;历史兼容路径也可能探 event.serverTimestamp / event.data._meta.serverTimestamp。不要把 ACP payload 内的 data._meta 和 daemon envelope _meta 混成同一个字段。

originatorClientId

上文事件表已经标注。带了已注册 X-Qwen-Client-Id 的请求触发的事件才有(规则见 08-session-lifecycle.md)。

Tool-call _meta(provenance / serverId)

跟上面 envelope 级 _meta 不是同一个:ACP session/update payload 自己也带 _meta,在 event.data._metaToolCallEmitterpackages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts)在 emitStart / emitResult / emitError 上盖两个字段:

字段类型解析规则(ToolCallEmitter.resolveToolProvenance
provenance'builtin' | 'mcp' | 'subagent'subagentMetasubagent(最高优先级);tool 名匹配 mcp__<server>__<tool>mcp;其它 → builtin
serverIdstring(仅 provenance === 'mcp' 时设)mcp__<serverId>__<tool> 命名启发提取

加上原本就有的 _meta.toolName(显示名)。

UI 据此渲染 builtin / MCP server badge / subagent 归属的 tool call,不必再去解析 tool 名字。

SDK reducer 行为

reduceDaemonSessionEvent(state, evt)packages/sdk-typescript/src/daemon/events.ts)把事件流投到 DaemonSessionViewState。三个 resync 相关字段:

  • awaitingResync: boolean —— state_resync_required 时置 true;调用方代码自己清(典型路径:调 POST /session/:id/load 重置 view state)。
  • resyncRequiredCount: number —— 观测帧计数(病态客户端可能不止一次 resync)。
  • lastResyncRequired?: DaemonStateResyncRequiredData —— 最近一次 payload。

awaitingResync = true 期间 reducer 自动跳过 delta 应用,只放行封闭的 RESYNC_PASSTHROUGH_TYPES 集合:

放行 type为什么 resync 期间也要应用
state_resync_required二次 resync(少见但可能)要更新 lastResyncRequired / resyncRequiredCount
session_died流终态信号即便在 resync limbo 也必须可见
session_closed同上
client_evicted同上
stream_error同上

lastEventId 在 resync limbo 期间仍然通过 advanceLastEventId(base) 单调推进,调用方重置并清掉 awaitingResync 后,后续 delta 对齐到正确游标。

状态与向前兼容

  • 新增已知 type → append 到 DAEMON_KNOWN_EVENT_TYPE_VALUES。老 SDK 对未识别 type 返回 undefinedasKnownDaemonEvent 的 fallback),计入 unrecognizedKnownEventCount;新 SDK 依赖判别式联合类型。
  • 给已有 payload 加可选字段 → 安全({ [key: string]: unknown } 是开的)。
  • 改已有 payload 的形状 → break;必须 bump EVENT_SCHEMA_VERSION 并依赖 caps.features.typed_event_schema_v2 之类的能力 tag 兼容。
  • id 是每 session 单调,订阅者级合成帧(client_evictedslow_client_warningstream_errorstate_resync_requiredreplay_completesession_snapshot)刻意无 id,防止其他订阅者看到序号断档。
  • originatorClientId 在 envelope 而非 data。F3 的 partial-vote / forbidden payload 同时也把它盖到 datamergeOriginator),view-state 消费方就不必保留 envelope。

依赖

配置

  • 默认广播:typed_event_schema(恒)、mcp_guardrail_events(恒)、permission_mediation(恒,modes 列出支持策略)。
  • 没有 env / 参数直接控制 schema 本身;杀手锏 QWEN_SERVE_NO_MCP_POOL=1 会让 MCP 事件的 scope 字段从 'workspace' 变成 缺失 / 'session'

注意 & 已知局限

  • 六种合成帧故意无 id,SDK 代码不能假设每个事件都有 id。
  • permission_partial_vote 只在 consensus 下出现;permission_forbiddendesignated / consensus / local-only 下出现,不在 first-responder 下出现。
  • mcp_child_refused_batch 只在 mode: 'enforce' 下出现,warn 模式从不拒绝。
  • auth_device_flow_* 事件不是 session-keyed;通过 DaemonSessionClient 消费时必须走 reduceDaemonAuthEvent,不要走 session reducer。

参考

  • packages/sdk-typescript/src/daemon/events.ts(整文件)
  • packages/acp-bridge/src/eventBus.tsEVENT_SCHEMA_VERSION
  • packages/cli/src/serve/capabilities.tstyped_event_schemamcp_guardrail_eventspermission_mediation)。
  • wire 参考:../qwen-serve-protocol.md