diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index e17d42d2bb..9accad167a 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -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 -2026-07-23-session-telemetry-otel-revival.md: 476073751a52989434798d4efa0013fc9908d6fa -2026-07-23-session-telemetry-otel-revival.zh.md: 78372554bc57ef298d399b7f01ba8a122964d5d5 +2026-07-23-session-telemetry-otel-revival.md: f790bafe07caa6da00621f395de62909be9ee238 +2026-07-23-session-telemetry-otel-revival.zh.md: 9b2cf657541cc08ba238c5b5d952f8908e825537 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index 476073751a..f790bafe07 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -28,6 +28,8 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry **Map onto OTel spans (GenAI semantic conventions) instead of logs.** Rejected for this revival: the branch implementation's log mapping is reviewed and shipped-shaped; the span model is lossy for forkable, interruptible sessions and belongs to a future consumer with real span queries to serve. +**Full-log replay when no handoff cursor survived (re-export constructor seeds).** Shipped in the first revival round, then narrowed: adoption now replays from the session's construction boundary (`Session.firstLiveSeq`, the constructor-seed length — a fact the session already validated but did not expose; `header.seedLength` cannot serve, it is the durable fork-lineage value and a resumed session's constructor seed is its full stored log). A resumed session's history already shipped from the previous process under the same id, and a fork's inherited prefix already shipped in the parent's stream — re-exporting either re-billed every resume for its full history and doubled query-time counts on OTLP backends with no native ingest dedupe. Receivers stitch fork lineage via `session.parent_id` + `session.seed_length`. What the narrowing gives up, consistently with the at-most-once stance: a resume no longer backfills records the previous process failed to deliver (telemetry unmounted then, or queued at crash) — the full replay's only real benefit, bought at the common case's expense. A deployment that states a backfill requirement needs the deferred outbox above, not replay. + **Forwarding the seam's turn-boundary `flush()` hint to the OTel provider's `forceFlush()`.** Shipped in the first revival round, then removed after three review rounds each found a new silent-loss path in the same wrapper state: a dispose racing an in-flight flush (the SDK's concurrent-flush guard makes shutdown's internal drain skip), overlapping hints displacing the retained promise, and the provider's fixed 30-second flush timeout rejecting while the processor still drains. Every path exists only because the forwarding made this backend the process's second flusher against undocumented SDK internals from the upstream experimental tree; with no `flush()` implemented, the batch processor is the only flusher, its `scheduledDelayMillis` (already deployment-tunable through the `processor` passthrough) governs export cadence, and `shutdown()`'s drain is complete by construction. Reinstate only if a deployment states a turn-boundary latency requirement `scheduledDelayMillis` cannot meet — and then by calling the retained `BatchLogRecordProcessor`'s own `forceFlush()`, never the provider's timeout-wrapped one. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index 78372554bc..9b2cf65754 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -28,6 +28,8 @@ Status: implemented **映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 +**handoff 游标未存活时全量回放日志(重新导出构造函数种子)。** 首轮复活曾交付此方案,其后收窄:收养现在从会话的构造边界起回放(`Session.firstLiveSeq`,即构造函数种子长度,这一事实会话早已校验过却未曾暴露;`header.seedLength` 不能胜任:它是持久保存的 fork 谱系(lineage)值,而恢复会话的构造函数种子是其完整的已存储日志)。恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀也已在父会话的流中发出;再次导出任何一者,都会让每次恢复为其完整历史重复付费,并在没有原生摄取去重的 OTLP 后端上使查询时的计数翻倍。接收端基于 `session.parent_id` + `session.seed_length` 拼接 fork 谱系。此次收窄放弃的内容与至多一次立场一致:恢复不再回填上一个进程未能投递的记录(彼时遥测未挂载,或崩溃时仍在队列中)——这本是全量回放唯一的真实收益,代价却由常见情形承担。提出回填要求的部署需要的是上文已推迟的 outbox,而不是回放。 + **将 seam 的轮次边界 `flush()` 提示转发到 OTel provider 的 `forceFlush()`。** 首轮复活曾交付此转发,其后移除:三轮评审在同一份包装层状态中各发现一条新的静默丢失路径——dispose 与进行中的 flush 之间的竞态(SDK 的并发 flush 防护会令 shutdown 的内部排空被跳过)、相互重叠的提示顶掉留存的 promise、以及 provider 固定的 30 秒 flush 超时在批处理器仍在排空时便 reject。这些路径存在的唯一原因,是该转发让这个后端成为进程内第二个执行 flush 的组件,面对的还是上游实验性(experimental)源码树中未见诸文档的 SDK 内部行为;不实现 `flush()` 时,批处理器就是唯一执行 flush 的组件,其 `scheduledDelayMillis`(已可由部署方经 `processor` passthrough 调优)决定导出节奏,`shutdown()` 的排空从构造上就是完整的。仅当某个部署提出 `scheduledDelayMillis` 无法满足的轮次边界延迟要求时才恢复此转发——且届时应调用留存的 `BatchLogRecordProcessor` 自身的 `forceFlush()`,绝不调用 provider 那个带超时包装的版本。 ## Consequences diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d255d1a70a..eb8bca7c15 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1342,7 +1342,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:606`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:620`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 454bd17c38..773a436b94 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -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 -session.md: d789ffcabb5cb0c744e265b61e322831c1d8a04f -session.zh.md: f4f102861db7403520e9f38cb56613e430718cbe +session.md: ae388f8de3a8b963a662cbe8d8736ab42ad378f9 +session.zh.md: cac769fc5c02952eefd5a01845dbb44d60e99ece diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d789ffcabb..ae388f8de3 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -370,6 +370,18 @@ declare class Session { readonly header: SessionHeader; /** The session identity, derived from its durable header's single copy. */ get id(): SessionId; + /** + * The first seq appended IN THIS PROCESS: the length of the constructor + * seed (0 without one). Events below it entered through construction — + * replay, fork, or resume — and were never published on the `session/event` + * firehose (constructor seeds do not emit), so consumers that replay the + * log as a publication substitute (telemetry adoption) start here. Distinct + * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed + * session's constructor seed is its full stored log, while its header keeps + * the original fork value — this field is the in-process construction fact + * and is deliberately not persisted. + */ + readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); /** * An immutable snapshot of the append-only event log. The snapshot is reused diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index f4f102861d..cac769fc5c 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -372,6 +372,18 @@ declare class Session { readonly header: SessionHeader; /** The session identity, derived from its durable header's single copy. */ get id(): SessionId; + /** + * The first seq appended IN THIS PROCESS: the length of the constructor + * seed (0 without one). Events below it entered through construction — + * replay, fork, or resume — and were never published on the `session/event` + * firehose (constructor seeds do not emit), so consumers that replay the + * log as a publication substitute (telemetry adoption) start here. Distinct + * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed + * session's constructor seed is its full stored log, while its header keeps + * the original fork value — this field is the in-process construction fact + * and is deliberately not persisted. + */ + readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); /** * An immutable snapshot of the append-only event log. The snapshot is reused diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index e1dad3ed89..b88404a94b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1935,7 +1935,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Session', - declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', }, { name: 'SessionAvailability', diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 5a6b274b3e..f502bbd92a 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -300,6 +300,19 @@ export class Session { return this.header.id } + /** + * The first seq appended IN THIS PROCESS: the length of the constructor + * seed (0 without one). Events below it entered through construction — + * replay, fork, or resume — and were never published on the `session/event` + * firehose (constructor seeds do not emit), so consumers that replay the + * log as a publication substitute (telemetry adoption) start here. Distinct + * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed + * session's constructor seed is its full stored log, while its header keeps + * the original fork value — this field is the in-process construction fact + * and is deliberately not persisted. + */ + readonly firstLiveSeq: number + constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) { if (seed) { // Validate the seed to the SAME invariants `append` enforces, so a @@ -332,6 +345,7 @@ export class Session { this.log.push(deepFreeze(snapshot)) } } + this.firstLiveSeq = this.log.length this.header = snapshotSessionHeader(id, header) } diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index 89ba658a6f..c53fa68694 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/README.i18n.yaml @@ -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 -README.md: 28fffc5f43f960de1a44700aa70050d792b81d4f -README.zh.md: 821969fea09487d9d4450e981a0d791a66dad3a1 +README.md: 8dd2c4f6535d6d3ea772f7669185f62c4694ca43 +README.zh.md: 42bc9b7ddbbf6d9cb104de35d9489e6e583e1df7 diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 28fffc5f43..8dd2c4f653 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -25,7 +25,7 @@ Records carry the complete `event.data` as the seam's `telemetry/redact` waterfa ## Field mapping -Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record absence (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). The marker means telemetry stopped observing the session cleanly — emitted at the session's own disposal, or at application teardown for sessions still running then; a marker followed by more of that session's events is a telemetry reload, not a session restart. +Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record absence (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). The marker means telemetry stopped observing the session cleanly — emitted at the session's own disposal, or at application teardown for sessions still running then; a marker followed by more of that session's events is a telemetry reload, not a session restart. Streams are not self-contained across lineage: a resumed session continues its own id's stream from where the previous process left off, and a forked session's stream starts at its inherited boundary — its prefix lives in the parent's stream, stitched via `session.parent_id` + `session.seed_length`. ## Model Experience diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 821969fea0..42bc9b7ddb 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -25,7 +25,7 @@ ## 字段映射 -seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`;`severity` → `severityNumber`/`severityText`(INFO 9 / WARN 13 / ERROR 17);`body` → 结构化日志 body;`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重、按严重级别告警,并通过 `shutdown` 记录的缺失检测崩溃(一个曾有活动、没有 `shutdown` 运维记录、且已然陈旧的会话,就是未干净结束的会话)。该标记的含义是遥测干净地停止了对该会话的观察:它在会话自身 dispose(资源释放)时发出,对于届时仍在运行的会话,则在应用拆卸时发出;标记之后又出现该会话的更多事件,说明发生的是遥测重载,而不是会话重启。 +seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`;`severity` → `severityNumber`/`severityText`(INFO 9 / WARN 13 / ERROR 17);`body` → 结构化日志 body;`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重、按严重级别告警,并通过 `shutdown` 记录的缺失检测崩溃(一个曾有活动、没有 `shutdown` 运维记录、且已然陈旧的会话,就是未干净结束的会话)。该标记的含义是遥测干净地停止了对该会话的观察:它在会话自身 dispose(资源释放)时发出,对于届时仍在运行的会话,则在应用拆卸时发出;标记之后又出现该会话的更多事件,说明发生的是遥测重载,而不是会话重启。跨谱系(lineage)的流并不自足:恢复的会话在其自身 id 的流上从上一个进程停止之处继续;fork 出的会话,其流从继承边界开始,前缀位于父会话的流中,由接收端基于 `session.parent_id` + `session.seed_length` 拼接。 ## 模型体验 diff --git a/packages/telemetry/session-telemetry/README.i18n.yaml b/packages/telemetry/session-telemetry/README.i18n.yaml index dd0da745e8..6c905a5ea1 100644 --- a/packages/telemetry/session-telemetry/README.i18n.yaml +++ b/packages/telemetry/session-telemetry/README.i18n.yaml @@ -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 -README.md: df0384d0528f0b3a95ded414444e1b11ea7d52bf -README.zh.md: d86fede206a4b2363f1deda86ff32d868d1db71d +README.md: 76f9b242ca0b4779a9ab10e5ae93bab1ffb5e581 +README.zh.md: d930dc92e41e85c597aaeff840e6e6b03712b06d diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index df0384d052..76f9b242ca 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -10,7 +10,7 @@ The telemetry seam: the CAPTURE side of session-event reporting, behind a backen ## Capture points -The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection — seed events from fork/resume never re-emit on the firehose), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (mark each session still alive at teardown, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). +The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (mark each session still alive at teardown, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). ## The redact waterfall @@ -18,7 +18,7 @@ Every record passes the `telemetry/redact` waterfall between projection and `emi ## The handoff cursor -A module-scope `WeakMap` marks the highest seq HANDED OFF (not delivered) per session, advanced at emit time. It survives reloads that do not re-evaluate this module — config re-applies and backend source reloads, which is where iteration happens; that asymmetry is why the cursor lives in the seam. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a full re-hand, absorbed by receiver-side dedupe on `(session.id, event.seq)`. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error. +A module-scope `WeakMap` marks the highest seq HANDED OFF (not delivered) per session, advanced at emit time. It survives reloads that do not re-evaluate this module — config re-applies and backend source reloads, which is where iteration happens; that asymmetry is why the cursor lives in the seam. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a re-hand from the session's construction boundary (`Session.firstLiveSeq` — seq 0 for a session born in this process), absorbed by receiver-side dedupe on `(session.id, event.seq)`. Constructor seeds never re-export: a resumed session's history shipped from the previous process under the same id, and a fork's inherited prefix lives in the parent's stream (receivers stitch on `session.parent_id` + `session.seed_length`). The accepted cost, consistent with at-most-once delivery: a resume does not backfill records a previous process failed to deliver — a deployment with a backfill requirement needs the deferred outbox, not replay. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error. ## The fixed chunk projection @@ -26,7 +26,7 @@ Only the first `assistant/chunk` of each `(turn, step)` ships; the rest are drop ## The logical record -`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError` and `turn/end` error reasons; WARN for `prompt/blocked`; INFO otherwise, including plugin-merged event types whose outcome semantics stay with their owners), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `telemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`. +`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError` and `turn/end` error reasons; WARN for `prompt/blocked`; INFO otherwise, including plugin-merged event types whose outcome semantics stay with their owners), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id`/`session.seed_length` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `telemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`. ## Model Experience diff --git a/packages/telemetry/session-telemetry/README.zh.md b/packages/telemetry/session-telemetry/README.zh.md index d86fede206..d930dc92e4 100644 --- a/packages/telemetry/session-telemetry/README.zh.md +++ b/packages/telemetry/session-telemetry/README.zh.md @@ -10,7 +10,7 @@ ## 捕获点 -协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影回读日志;来自 fork 或恢复的种子事件绝不会在 firehose 上再次发出)、`session/event`(投影、深拷贝、脱敏、交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘发出该会话的 `shutdown` 运维记录,接收端正是在这个边缘锚定崩溃检测;随后将该会话退役,因此长生命周期的后端既不会保留已关闭的会话,也不会在卸载时再次标记它们)、`agent/error`(唯一的实时总线转发;轮次封闭机制在结构上决定了这些错误进不了日志)、一个 dispose effect(拆卸时先标记每个仍存活的会话,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。 +协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏、交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘发出该会话的 `shutdown` 运维记录,接收端正是在这个边缘锚定崩溃检测;随后将该会话退役,因此长生命周期的后端既不会保留已关闭的会话,也不会在卸载时再次标记它们)、`agent/error`(唯一的实时总线转发;轮次封闭机制在结构上决定了这些错误进不了日志)、一个 dispose effect(拆卸时先标记每个仍存活的会话,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。 ## 脱敏 waterfall @@ -18,7 +18,7 @@ ## handoff 游标 -一个模块作用域的 `WeakMap` 记录每个会话已交接(而非已投递)的最高 seq,在 emit 时推进。游标在不重新求值本模块的重载(配置重新应用、后端源码重载)中存活,而迭代恰恰发生在这类重载中;这种不对称正是游标放在 seam 一侧的原因。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为全量重新交接,由接收端基于 `(session.id, event.seq)` 的去重吸收。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。 +一个模块作用域的 `WeakMap` 记录每个会话已交接(而非已投递)的最高 seq,在 emit 时推进。游标在不重新求值本模块的重载(配置重新应用、后端源码重载)中存活,而迭代恰恰发生在这类重载中;这种不对称正是游标放在 seam 一侧的原因。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0),由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接)。由此接受的代价与至多一次(at-most-once)投递一致:恢复不会回填上一个进程未能投递的记录;有回填要求的部署需要的是已推迟的 outbox,而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。 ## 固定分片投影 @@ -26,7 +26,7 @@ ## 逻辑记录 -`TelemetryRecord` 包含:`channel`(`ledger` | `ops`)、`time`(epoch 毫秒)、`severity`(预先映射好的严重级别:`tool/result.isError` 与 `turn/end` 的错误原因映射为 ERROR,`prompt/blocked` 映射为 WARN,其余为 INFO,包括结果语义仍归其所有者的插件合并事件类型)、只含身份信息的 `attributes`(`session.id`、`event.type`、`event.seq`,header 中存在时再加 `session.cwd`/`session.parent_id`),以及作为 `body` 的完整深拷贝 `event.data`,且以脱敏后的内容为准。运维记录携带 `telemetry.op`(`agent-error` | `shutdown`)和 `session.id`,并刻意不带 `event.seq`/`event.type`:它们是用来告警的信号,不是用来累加的条目。交接之后的投递由后端 SDK 负责;重复仍然可能出现(无游标的重新收养、SDK 重试),因此接收端基于 `(session.id, event.seq)` 去重。 +`TelemetryRecord` 包含:`channel`(`ledger` | `ops`)、`time`(epoch 毫秒)、`severity`(预先映射好的严重级别:`tool/result.isError` 与 `turn/end` 的错误原因映射为 ERROR,`prompt/blocked` 映射为 WARN,其余为 INFO,包括结果语义仍归其所有者的插件合并事件类型)、只含身份信息的 `attributes`(`session.id`、`event.type`、`event.seq`,header 中存在时再加 `session.cwd`/`session.parent_id`/`session.seed_length`),以及作为 `body` 的完整深拷贝 `event.data`,且以脱敏后的内容为准。运维记录携带 `telemetry.op`(`agent-error` | `shutdown`)和 `session.id`,并刻意不带 `event.seq`/`event.type`:它们是用来告警的信号,不是用来累加的条目。交接之后的投递由后端 SDK 负责;重复仍然可能出现(无游标的重新收养、SDK 重试),因此接收端基于 `(session.id, event.seq)` 去重。 ## 模型体验 diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index d500a66d89..a5b552c765 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -117,17 +117,23 @@ export class TelemetryCoordinator { /** * Adopt a session: replay its log THROUGH the projection from the handoff - * cursor (or from the start when no cursor survived), then rely on the - * firehose for everything after. Events at or below the cursor still feed - * the projection state (first-chunk tracking) without being re-handed, so - * a resumed fiber drops mid-step chunk continuations exactly like the - * fiber that saw the step begin. + * cursor, then rely on the firehose for everything after. When no cursor + * survived, replay starts at the session's construction boundary + * (`firstLiveSeq`), not seq 0: constructor seeds never publish on the + * firehose, and their content already left the process under another + * identity — the same id in a previous process (resume) or the parent's + * stream (fork, stitched by receivers via `session.seed_length`). Events + * at or below the start still feed the projection state (first-chunk + * tracking) without being re-handed, so a resumed fiber drops mid-step + * chunk continuations exactly like the fiber that saw the step begin. The + * cost, accepted with the seam's at-most-once stance: a resume no longer + * backfills records a previous process failed to deliver. * @param session - the live session to adopt; a second adoption is a no-op. */ private adopt(session: Session): void { if (this.adopted.has(session)) return this.adopted.add(session) - const cursor = handoffCursor.get(session) ?? -1 + const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1 // Containment is PER EVENT, matching the firehose: one rejected record // is withheld fail-closed while the rest of the historical replay // proceeds — wrapping the whole loop would let a single failure silently @@ -264,8 +270,11 @@ function identityOf(session: Session, event: SessionEvent): Record { }) describe('TelemetryCoordinator adoption', () => { - it('reads seeded events back at adoption (fork/resume seeds never re-emit)', async () => { + it('starts export at the construction boundary: seeded history never re-exports', async () => { const backend = new FakeBackend() const ctx = new Context() await ctx.plugin(SessionStore) const parent = liveSession(ctx, 'seed-parent') appendTurn(parent) - ctx.sessions.create(SessionId('seeded'), { seed: [...parent.events], meta: {} }) + const child = ctx.sessions.create(SessionId('seeded'), { seed: [...parent.events], meta: {} }) await ctx.plugin({ name: 'fake-telemetry', inject: ['sessions'], apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), }) + // The live parent (no constructor seed) replays in full; the child's + // inherited prefix already left the process under another identity (the + // parent's id here; the same id in a previous process for a resume) and + // must not be re-exported — only its live suffix ships. const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']]) - expect(seqs).toEqual(expect.arrayContaining([ - ['seed-parent', 0], ['seed-parent', 1], - ['seeded', 0], ['seeded', 1], - ])) + expect(seqs).toEqual(expect.arrayContaining([['seed-parent', 0], ['seed-parent', 1]])) + expect(seqs.filter(([id]) => id === 'seeded')).toEqual([]) + child.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']])) + .toEqual(expect.arrayContaining([['seeded', 2]])) + }) + + it('resume shape: a full-log seed exports nothing yet still rebuilds the chunk projection', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const donor = ctx.sessions.create(SessionId('donor'), { meta: {} }) + donor.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + donor.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }) + const resumed = ctx.sessions.create(SessionId('resumed'), { seed: [...donor.events], meta: {} }) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + const ofResumed = () => backend.ledger() + .filter(r => r.attributes['session.id'] === 'resumed') + .map(r => r.attributes['event.seq']) + expect(ofResumed()).toEqual([]) + // The seed fed the projection: the (turn 1, step 1) first chunk already + // shipped from the original process, so its continuation is re-dropped… + resumed.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'continuation' } }) + expect(ofResumed()).toEqual([]) + // …while a new step's first chunk exports normally. + resumed.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'next step' } }) + expect(ofResumed()).toEqual([3]) + }) + + it('stamps session.seed_length from the header so receivers can stitch fork streams', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const parent = liveSession(ctx, 'stitch-parent') + appendTurn(parent) + const child = ctx.sessions.create(SessionId('stitch-child'), { + seed: [...parent.events], + meta: { parentSession: SessionId('stitch-parent'), seedLength: 2 }, + }) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + child.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const record = backend.ledger().find(r => r.attributes['session.id'] === 'stitch-child')! + expect(record.attributes['session.parent_id']).toBe('stitch-parent') + expect(record.attributes['session.seed_length']).toBe(2) }) it('adopts exactly once when created fires after the sweep', async () => {