* fix(core): Stop chat recording after write failure Keep the canonical JSONL write chain rejected after the first asynchronous failure so queued descendants are skipped and flush reports the original error consistently. Cover sticky failures across ordinary, strict, parent-session, ACP close, rename, branch, and rewind paths. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix: Surface chat recording failures Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(core): Await custom title persistence Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6743) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(cli): clarify degraded branch behavior Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(acp): align recording state entry type Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix: address PR review feedback (#6743) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix: address durability review findings (#6743) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(core): allow artifact migration after recording failure Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs(daemon): correct UI event counts Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
36 KiB
Typed Daemon Event Schema v1
Overview
Every SSE frame emitted by the daemon on GET /session/:id/events has the shape { id, v, type, data, originatorClientId?, _meta? }. v: 1 is the current EVENT_SCHEMA_VERSION. type comes from the closed, version-pinned DAEMON_KNOWN_EVENT_TYPE_VALUES set in packages/sdk-typescript/src/daemon/events.ts. The envelope _meta field is stamped at the SSE write boundary by formatSseFrame() in packages/cli/src/serve/routes/sse-events.ts; see Envelope-level metadata.
The SDK exposes asKnownDaemonEvent(evt). It returns a discriminated KnownDaemonEvent for known event types and undefined for other types. SDK consumers can therefore handle forward compatibility without requiring a lockstep SDK upgrade when a newer daemon adds an event type; the session reducer records those as unrecognizedKnownEventCount.
The wire format lives in ../qwen-serve-protocol.md. This page is the payload contract for each event.
Responsibilities
- Provide the single source of truth for the event vocabulary (
DAEMON_KNOWN_EVENT_TYPE_VALUES). - Provide a typed envelope for each event type (
DaemonEventEnvelope<TType, TData>). - Provide pure reducers (
reduceDaemonSessionEvent,reduceDaemonAuthEvent) that project an event stream into SDK view state. - Broadcast the
typed_event_schemacapability tag as an informational signal. If the tag is absent,asKnownDaemonEventstill falls back tounknown.
Event vocabulary
Grouped by domain.
Core session
| Type | Direction | Trigger | Key payload fields |
|---|---|---|---|
session_update |
S->C | Any ACP sessionUpdate notification: agent text, thought, tool call, or plan |
sessionUpdate: string, content?: ... (opaque ACP shape) |
session_metadata_updated |
S->C | PATCH /session/:id/metadata |
sessionId, displayName? |
session_died |
S->C terminal | channel.exited |
sessionId, reason, exitCode? | null, signalCode? | null |
session_closed |
S->C terminal | DELETE /session/:id or programmatic close |
sessionId, reason: 'client_close' | string, closedBy? |
session_snapshot |
S->C synthetic | Snapshot frame after SSE attach / replay | sessionId, currentModelId: string | null, currentApprovalMode: string | null, recordingDegraded: boolean |
session_recording_degraded |
S->C | The session transcript writer permanently stopped after an asynchronous write failure | sessionId, reason: 'write_failed' |
Subscriber-level synthetic frames
| Type | Trigger | Notes |
|---|---|---|
client_evicted |
Per-subscriber EventBus queue overflow. No id |
reason: 'queue_overflow' | 'queue_bytes_overflow' | string, droppedAfter?: number, queueSize?: number, maxQueued?: number, queuedBytes?: number, maxQueuedBytes?: number, eventBytes?: number; terminal only for the current subscriber, while the session remains alive. |
slow_client_warning |
Live frame backlog or live serialized-byte backlog >= 75%; force-pushed and has no id |
queueSize, maxQueued, lastEventId, queuedBytes?, maxQueuedBytes?, threshold?: 'frames' | 'bytes' | 'frames_and_bytes'; re-armed after both frame and byte measurements drop below 37.5%. |
stream_error |
SubscriberLimitExceededError or another route stream error |
error: string; terminal for the subscription. |
state_resync_required |
subscribe({lastEventId}) detects that the daemon ring no longer holds [lastEventId+1, earliestInRing-1], or the client cursor is from a previous bus epoch. Force-pushed before remaining replay frames and has no id. |
reason: 'ring_evicted' | 'epoch_reset' | string, lastDeliveredId: number, earliestAvailableId: number. This is a recovery signal, not terminal: the SSE stream stays open and replay + live frames continue. The SDK reducer sets awaitingResync = true and skips deltas until the caller resets with loadSession. |
history_truncated |
POST /session/:id/load returns a bounded replay snapshot after older in-memory replay entries were dropped. Prepended to compactedReplay and has no id. |
reason: 'replay_window_exceeded', truncatedEvents: number, retainedEvents: number, maxBytes: number, truncatedTurns?: number, fullTranscriptAvailable: boolean. This is a status marker, not a resync request; clients render it and continue applying retained replay. |
replay_complete |
Id-less sentinel emitted after the Last-Event-ID replay loop finishes, for both clean replay and ring-evicted paths, even when data.replayedCount === 0. No id |
replayedCount: number; lets consumers remove catch-up UI deterministically without a timeout. |
fullTranscriptAvailable is a boolean capability flag, not a literal true type. Current daemons emit true when /session/:id/transcript can be used to page the persisted transcript; older or constrained daemons may emit false, and clients should keep rendering the bounded replay normally.
Permissions (F3 + base)
| Type | Direction | Trigger | Key payload fields |
|---|---|---|---|
permission_request |
S->C | Agent calls requestPermission |
requestId, sessionId, toolCall, options[]; the envelope stamps originatorClientId from the prompt originator. |
permission_resolved |
S->C | Mediator has decided | requestId, outcome (ACP PermissionOutcome) |
permission_already_resolved |
S->C | Vote arrives after the request was already decided | requestId, sessionId, outcome |
permission_partial_vote |
S->C | consensus policy records a non-final vote |
requestId, sessionId, votesReceived, votesNeeded (>= 1), quorum, optionTallies: Record<string, number>, originatorClientId? |
permission_forbidden |
S->C | Policy rejects a vote | requestId, sessionId, clientId?, reason: 'designated_mismatch' | 'remote_not_allowed', originatorClientId?; anonymous voters omit clientId. |
Models
| Type | Direction | Payload |
|---|---|---|
model_switched |
S->C | sessionId, modelId |
model_switch_failed |
S->C | sessionId, requestedModelId, error: string |
MCP guardrails (PR 14b + F2)
| Type | Direction | Payload |
|---|---|---|
mcp_budget_warning |
S->C | liveCount, reservedCount, budget, thresholdRatio: 0.75, mode: 'warn' | 'enforce', scope?: 'workspace' | 'session' |
mcp_child_refused_batch |
S->C | refusedServers: [{ name, transport, reason: 'budget_exhausted' }], budget, liveCount, reservedCount, mode: 'enforce', scope?: 'workspace' | 'session' |
mcp_server_restarted |
S->C | serverName, durationMs, entryIndex? for F2 multi-entry pool restarts |
mcp_server_restart_refused |
S->C | serverName, reason: 'budget_would_exceed' | 'in_flight' | 'disabled' | 'restart_failed', entryIndex?, details?. The fourth value, restart_failed, carries an underlying hard failure for pool-mode multi-entry restart. MCP_RESTART_REFUSED_REASONS rejects unknown reasons; an older SDK reducer silently drops additive new reason values because parseDaemonEvent returns undefined. Ship a new reason with an SDK that knows it. |
Mutation control (Wave 4 PR 16+17)
| Type | Direction | Payload |
|---|---|---|
memory_changed |
S->C | File memory: scope: 'workspace' | 'global', filePath, mode, bytesWritten; managed memory: scope: 'managed', source, taskId, touchedScopes |
agent_changed |
S->C | change: 'created' | 'updated' | 'deleted', name, level: 'project' | 'user' |
approval_mode_changed |
S->C | sessionId, previous, next, persisted: boolean |
tool_toggled |
S->C | toolName, enabled; affects the next ACP child spawn and does not mutate already-running sessions. |
settings_changed |
S->C | Workspace settings write completed. Payload is open; consumers should refresh with read-after-write. |
settings_reloaded |
S->C | Daemon workspace service reread settings. Payload is open. |
trust_change_requested |
S->C | workspaceCwd, desiredState: 'trusted' | 'untrusted', reason? |
workspace_initialized |
S->C | path, action: 'created' | 'overwrote' | 'noop', originatorClientId? |
github_setup_completed |
S->C | releaseTag, readmeUrl, secretsUrl?, workflows: [{path, status, sizeBytes?, error?}], gitignore: {path, status, added?, error?} |
memory_changed also covers sessionless managed-memory tasks. For those
payloads, scope is "managed", source is one of
"workspace_memory_remember", "workspace_memory_forget", or
"workspace_memory_dream", taskId is the queued task id, and
touchedScopes lists the managed memory scopes that changed ("user" and/or
"project"). No event is emitted when a remember/forget/dream task completes
without touching managed memory.
Auth device flow (PR 21)
These events are workspace-keyed, not session-keyed. The session reducer treats them as no-ops; reduceDaemonAuthEvent projects them into workspace-level state.
| Type | Direction | Payload |
|---|---|---|
auth_device_flow_started |
S->C | deviceFlowId, providerId, expiresAt |
auth_device_flow_throttled |
S->C | deviceFlowId, intervalMs |
auth_device_flow_authorized |
S->C | deviceFlowId, providerId, expiresAt?, accountAlias? |
auth_device_flow_failed |
S->C | deviceFlowId, errorKind, hint? |
auth_device_flow_cancelled |
S->C | deviceFlowId |
MCP runtime mutation
| Type | Direction | Trigger | Key payload fields |
|---|---|---|---|
mcp_server_added |
S->C | Server added at runtime through POST /workspace/mcp/servers |
name, transport, replaced, shadowedSettings, toolCount, originatorClientId |
mcp_server_removed |
S->C | Server removed at runtime | name, wasShadowingSettings, originatorClientId |
Extensions lifecycle
| Type | Direction | Trigger | Key payload fields |
|---|---|---|---|
extensions_changed |
S->C | Background extension install/refresh work completed or status change | refreshed, failed, status?: 'installed' | 'enabled' | 'disabled' | 'updated' | 'uninstalled' | 'failed', source?, name?, version?, error? |
Mid-turn message injection
| Type | Direction | Trigger | Key payload fields |
|---|---|---|---|
mid_turn_message_injected |
S->C | Web-shell or remote client injected messages into a running turn via POST /session/:id/inject |
sessionId, messages: string[], originatorClientId?; consumers MUST compare originatorClientId to their own id before deduping. |
Turn lifecycle / assistant pushes
| Type | Direction | Trigger | Key payload fields |
|---|---|---|---|
prompt_cancelled |
S->C | Prompt was cancelled through explicit cancelSession route or originator SSE disconnect |
Envelope stamps originatorClientId for the canceling client. This means "cancellation requested", not "cancellation confirmed". Peer subscribers learn that the prompt has ended. |
turn_complete |
S->C | A turn completed successfully | sessionId, stopReason, promptId?. promptId links to non-blocking prompt responses (202). The SDK matches SSE events to the originating prompt through it. |
turn_error |
S->C | A turn failed | sessionId, message, code?, promptId?; same promptId correlation mechanism. |
session_rewound |
S->C | POST /session/:id/rewind succeeded |
sessionId, promptId, targetTurnIndex, filesChanged[], filesFailed[], originatorClientId? |
session_branched |
S->C | POST /session/:id/branch created a branch from an existing session |
sourceSessionId, newSessionId, displayName, originatorClientId? |
followup_suggestion |
S->C | ACP child generated ghost-text follow-up suggestions after end_turn, forwarded over per-session SSE |
sessionId, suggestion, promptId; wire only carries suggestions whose getFilterReason()===null. Clients render them as input-placeholder ghost text and invalidate them on next sendPrompt. |
user_shell_command |
S->C | User started a shell command through POST /session/:id/shell; fanned out to other subscribers in the same session |
sessionId, command, shellId, originatorClientId?. There is no typed DaemonXxxData interface yet; asKnownDaemonEvent returns undefined and the UI normalizer parses it ad hoc. |
user_shell_result |
S->C | Result of the shell command above | sessionId, shellId, exitCode, output, aborted. Same ad hoc parsing note as user_shell_command. |
Architecture
| Concern | Source | Notes |
|---|---|---|
EVENT_SCHEMA_VERSION = 1 |
packages/acp-bridge/src/eventBus.ts |
Sent on every frame. |
DAEMON_KNOWN_EVENT_TYPE_VALUES |
packages/sdk-typescript/src/daemon/events.ts |
Closed list with 53 types. |
DaemonEventEnvelope<TType, TData> |
events.ts |
Generic envelope. |
DaemonKnownEventType |
events.ts |
typeof DAEMON_KNOWN_EVENT_TYPE_VALUES[number]. |
| Per-event payload types | events.ts |
Most event types have a DaemonXxxData interface; user_shell_* is currently parsed ad hoc by the UI normalizer. |
asKnownDaemonEvent(evt) |
events.ts |
Returns KnownDaemonEvent | undefined. |
reduceDaemonSessionEvent(state, evt) |
events.ts |
Projects into DaemonSessionViewState. |
reduceDaemonAuthEvent(state, evt) |
events.ts |
Projects into DaemonAuthState. |
isWorkspaceScopedBudgetEvent(evt) |
events.ts |
Detects F2 scope: 'workspace'. |
DaemonSessionViewState
reduceDaemonSessionEvent fills this view state. CLI TUI adapter, DaemonChannelBridge, and VS Code IDE consume it. Key fields:
alive: boolean- becomesfalseafter a terminal frame (session_died,session_closed,client_evicted,stream_error).currentModelId?: string- frommodel_switched.displayName?: string- fromsession_metadata_updated.recordingDegraded: boolean- sticky session recording state fromsession_recording_degraded; an explicitsession_snapshot.recordingDegradedvalue is authoritative.pendingPermissions: Record<string, DaemonPermissionRequestData>- open requests keyed byrequestId; cleared bypermission_resolved/permission_already_resolved.lastSessionUpdate?: DaemonSessionUpdateData- latestsession_update.lastModelSwitchFailure?: DaemonModelSwitchFailedData- frommodel_switch_failed.terminalEvent?- raw terminal event.streamError?: DaemonStreamErrorData- lateststream_errorpayload.unrecognizedKnownEventCount,lastUnrecognizedKnownEvent?- event was recognized byasKnownDaemonEventbut the reducer has no dedicated state for it yet.droppedPermissionRequestCount,lastDroppedPermissionRequestId?- malformed permission request could not enter the pending map.unmatchedPermissionResolutionCount,lastUnmatchedPermissionResolutionId?- permission resolution had no matching pending request.slowClientWarningCount,lastSlowClientWarning?- fromslow_client_warning.mcpBudgetWarningCount,lastMcpBudgetWarning?- frommcp_budget_warning.mcpChildRefusedBatchCount,lastMcpChildRefusedBatch?- frommcp_child_refused_batch.lastWorkspaceMutation?,lastWorkspaceMutationType?- frommemory_changed/agent_changed.approvalMode?,approvalModeChangedCount,lastApprovalModeChange?- fromapproval_mode_changed.toolToggleCount,lastToolToggle?- fromtool_toggled.workspaceInitCount,lastWorkspaceInit?- fromworkspace_initialized.mcpRestartCount,lastMcpRestart?- frommcp_server_restarted.mcpRestartRefusedCount,lastMcpRestartRefused?- frommcp_server_restart_refused.settings_changed/settings_reloaded- recognized byasKnownDaemonEvent; the session reducer does not maintain dedicated view-state fields, and UIs usually treat them as refresh signals.permissionVoteProgress: Record<string, DaemonPermissionPartialVoteData>- consensus voting progress.forbiddenVotes: DaemonPermissionForbiddenData[],forbiddenVoteCount- policy-rejected vote records, capped at 32.awaitingResync: boolean- set bystate_resync_required; cleared when consumer resets view state.resyncRequiredCount,lastResyncRequired?- resync observability.lastFollowupSuggestion?: DaemonFollowupSuggestionData- latest follow-up suggestion pushed by daemon.lastTurnComplete?: DaemonTurnCompleteData- latest successful turn completion.lastTurnError?: DaemonTurnErrorData- latest turn error.rewindCount,lastRewind?,lastBranch?- latest rewind / branch events.
DaemonAuthState
One entry per providerId, driven by auth_device_flow_*. Each flow exposes { deviceFlowId, status, providerId, expiresAt?, lastThrottleIntervalMs?, lastError? }.
Flow
Producer side
flowchart LR
A["ACP child notification"] --> B["BridgeClient.sessionUpdate /<br/>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["Assign id + v=1, push to ring"]
F --> G["Fan out to all subscribers"]
Consumer side (SDK)
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++<br/>(forward-compat)"]
Envelope-level metadata
Beyond each event's data payload, the daemon stamps two envelope-level fields.
_meta.serverTimestamp - daemon clock
EventBus.publish() in packages/acp-bridge/src/eventBus.ts stamps _meta.serverTimestamp when the event enters the bus. The BridgeEvent type includes _meta?: Record<string, unknown>, so internal daemon consumers do see _meta on every bus-published event. formatSseFrame() in packages/cli/src/serve/routes/sse-events.ts provides a fallback timestamp only for synthetic frames (e.g. stream_error) that bypass EventBus.publish.
{
"id": 47,
"v": 1,
"type": "session_update",
"data": { ... },
"_meta": { "serverTimestamp": 1716287345123 }
}
The merge preserves any existing _meta keys from the input event
({...input._meta, serverTimestamp: Date.now()}). Producers may attach
additional envelope-level _meta keys; EventBus.publish merges them with the
timestamp rather than overwriting.
Why it matters: multi-client UIs that render relative time or sort transcript blocks should use server time instead of each browser/tab/phone local clock. Server stamping keeps ordering consistent across clients.
SDK access: prefer event._meta?.serverTimestamp. Compatibility paths may also probe event.serverTimestamp or event.data._meta.serverTimestamp. Do not mix ACP payload data._meta with daemon envelope _meta.
originatorClientId
Events triggered by a request that carried a registered X-Qwen-Client-Id may stamp this field. See 08-session-lifecycle.md.
Tool-call _meta (provenance / serverId)
This is separate from envelope _meta: ACP session/update payloads can carry their own _meta in event.data._meta. ToolCallEmitter (packages/cli/src/acp-integration/session/emitters/tool-call-emitter.ts) stamps two fields on emitStart, emitResult, and emitError:
| Field | Type | Resolution rule |
|---|---|---|
provenance |
'builtin' | 'mcp' | 'subagent' |
ToolCallEmitter.resolveToolProvenance: subagentMeta wins with subagent; tool name matching mcp__<server>__<tool> maps to mcp; everything else maps to builtin. |
serverId |
string only when provenance === 'mcp' |
Extracted heuristically from mcp__<serverId>__<tool>. |
The existing _meta.toolName display name is preserved. UI uses these fields to render builtin / MCP server / subagent badges without reparsing the tool name.
SDK reducer behavior
reduceDaemonSessionEvent(state, evt) in packages/sdk-typescript/src/daemon/events.ts projects the stream into DaemonSessionViewState. The resync-related fields are:
awaitingResync: boolean- set bystate_resync_required; caller clears it, typically afterPOST /session/:id/loadresets view state.resyncRequiredCount: number- observability counter.lastResyncRequired?: DaemonStateResyncRequiredData- latest payload.
While awaitingResync = true, the reducer skips delta application and only allows the closed RESYNC_PASSTHROUGH_TYPES set:
| Passthrough type | Why it is still applied during resync |
|---|---|
state_resync_required |
Rare second resync should update lastResyncRequired / resyncRequiredCount. |
session_died |
Terminal stream signal must remain visible during resync. |
session_closed |
Same as above. |
client_evicted |
Same as above. |
stream_error |
Same as above. |
session_snapshot |
Full-state authoritative frame; safe to apply during resync. |
session_recording_degraded |
Sticky safety signal independent of transcript delta state. |
lastEventId still advances monotonically through advanceLastEventId(base) during resync. After the caller resets and clears awaitingResync, subsequent deltas align to the correct cursor.
reduceDaemonAuthEvent projects device-flow events into workspace-level auth
state entries shaped like
{deviceFlowId, status, providerId, expiresAt?, lastThrottleIntervalMs?, lastError?}
conceptually. In code the reducer stores status, errorKind, hint,
intervalMs, lastSeenEventId, authorizedExpiresAt, and accountAlias on
DaemonDeviceFlowReducerState; the daemon event payloads themselves remain the
per-event shapes listed above.
State and forward compatibility
- Add a known event type by appending to
DAEMON_KNOWN_EVENT_TYPE_VALUES. Old SDKs returnundefinedfor unrecognized event types through the fallback path and incrementunrecognizedKnownEventCount; new SDKs rely on the discriminated union. - Adding optional fields to an existing payload is safe because payloads are open (
{ [key: string]: unknown }). - Changing an existing payload shape is breaking and must bump
EVENT_SCHEMA_VERSIONplus advertise a compatible capability tag such ascaps.features.typed_event_schema_v2. idis per-session monotonic. Subscriber-level synthetic frames (client_evicted,slow_client_warning,stream_error,state_resync_required,replay_complete,session_snapshot) intentionally have no id so other subscribers do not see gaps.originatorClientIdlives on the envelope rather thandata. F3 partial-vote / forbidden payloads also merge it intodatathroughmergeOriginatorso view-state consumers do not need to retain the envelope.
Dependencies
10-event-bus.md- delivery channel.11-capabilities-versioning.md- how SDKs preflighttyped_event_schema,mcp_guardrail_events, andpermission_mediation.04-permission-mediation.md- how permission events are produced.13-sdk-daemon-client.md-asKnownDaemonEvent, reducers, and view-state shape.
Configuration
- Always advertised:
typed_event_schema,mcp_guardrail_events, andpermission_mediation(with supported policy modes). - No env var or flag directly controls the schema itself.
QWEN_SERVE_NO_MCP_POOL=1changes MCP eventscopefrom'workspace'to absent or'session'.
Caveats and known limits
- Six synthetic frame types intentionally have no
id; SDK code must not assume every event has an id. permission_partial_voteonly appears underconsensus.permission_forbiddenappears underdesignated,consensus, andlocal-only, but not underfirst-responder.mcp_child_refused_batchonly appears inmode: 'enforce';warnmode never refuses.auth_device_flow_*events are not session-keyed. When consuming throughDaemonSessionClient, usereduceDaemonAuthEventfor them rather than the session reducer.
References
packages/sdk-typescript/src/daemon/events.tspackages/acp-bridge/src/eventBus.ts(EVENT_SCHEMA_VERSION)packages/cli/src/serve/capabilities.ts(typed_event_schema,mcp_guardrail_events,permission_mediation)- Wire reference:
../qwen-serve-protocol.md