@lqc123qwe/car-runtime 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,8 +4,17 @@
4
4
  * 口径(§3.2.M8.5 关键约束,逐条落地):
5
5
  * - 默认关:无端点配置时不初始化任何 exporter——无配置 = noop 句柄 + 零出站(可机器断言);
6
6
  * - 显式开:用户配置端点即视为开启(CAR_OTEL_ENDPOINT 或显式 config 传入);
7
- * - 端点归用户:数据只发往用户自配 E-05;OTLP/HTTP JSON(fetch),导出超时 5s、0 重试、
8
- * 端点不可达静默丢弃(BD-05——不抛错、不重试、不落审计);
7
+ * - 端点归用户:数据只发往用户自配 E-05;OTLP/HTTP JSON(fetch),导出超时 5s;
8
+ * 1.2-S3 生产级策略(D-12b 口径修订,2026-09-27):**0 重试 → 有界重试 ≤2**(仅 429/5xx/
9
+ * 网络错误,退避 1s/2s;4xx 业务错不重试)——重试耗尽静默丢弃 + droppedExports 计数,
10
+ * 永不抛错(BD-05「不抛错、不落审计」收紧保持);隐私三原则不变(数据仍只发用户自配端点);
11
+ * - 采样(1.2-S3 加法):head 采样于 span.end() 决策——always_on(缺省,全量,1.1 行为不变)/
12
+ * always_off / { ratio }(概率);被采出计入 spansSampledOut;采样只减少出站数据(隐私正向);
13
+ * - 批处理上限(1.2-S3 加法):maxBatchSize 单请求分批(512)+ maxQueueSize buffer 上限
14
+ * (2048,溢出丢最旧 + queueOverflows 计数)——内存有界;
15
+ * - env 通道(1.2-S3 加法):telemetryConfigFromEnv——CAR_OTEL_ENDPOINT(唯一开关)/
16
+ * CAR_OTEL_SAMPLING(always_on|always_off|0..1,非法值保持缺省——遥测面禁 fail-hard)/
17
+ * CAR_OTEL_INTERVAL_MS / CAR_OTEL_SERVICE_NAME;
9
18
  * - 严格解耦:本文件零 import 自 session/*(遥测与审计日志禁止混流)——测试做静态断言;
10
19
  * - S17 零内容计数器(telemetry/metrics.ts)是登记表兜底通道,与本门面并存互补:
11
20
  * 本面是 OTel 形态的出站通道,不承接 S17 的零内容红线语义(内容治理属调用方);
@@ -14,6 +23,8 @@
14
23
  */
15
24
  import { randomBytes } from 'node:crypto'
16
25
 
26
+ export type SamplingSpec = 'always_on' | 'always_off' | { ratio: number }
27
+
17
28
  export interface TelemetryConfig {
18
29
  /** 用户自配 OTLP/HTTP 端点(如 https://otel.example.internal)——无值 = 默认关 */
19
30
  endpoint?: string
@@ -23,6 +34,19 @@ export interface TelemetryConfig {
23
34
  exportTimeoutMs?: number
24
35
  /** 自动导出间隔 ms;0(缺省)= 仅手动 flush(测试/收口时点调用) */
25
36
  intervalMs?: number
37
+ // —— 1.2-S3 生产级策略(加法;缺省值保持 1.1 行为)——
38
+ /** 有界重试上限(仅 429/5xx/网络错误;§3.5.4 基线 ≤2;D-12b 口径修订) */
39
+ retryLimit?: number
40
+ /** 重试退避序列 ms(缺省 [1000, 2000];测试可注入) */
41
+ retryBackoffMs?: number[]
42
+ /** 采样(缺省 always_on = 全量);head 决策于 span.end() */
43
+ sampling?: SamplingSpec
44
+ /** 单请求 spans 上限(超限分批;缺省 512) */
45
+ maxBatchSize?: number
46
+ /** span buffer 上限(溢出丢最旧 + queueOverflows 计数;缺省 2048) */
47
+ maxQueueSize?: number
48
+ /** 测试注入睡眠(退避可观测;缺省真实定时器) */
49
+ sleep?: (ms: number) => Promise<void>
26
50
  }
27
51
 
28
52
  export interface TelemetrySpan {
@@ -47,9 +71,14 @@ export interface TelemetryMeter {
47
71
 
48
72
  export interface TelemetryStats {
49
73
  spansEnded: number
74
+ /** 1.2-S3 加法:head 采样被采出的 span 数(缺省 always_on 下恒 0) */
75
+ spansSampledOut: number
76
+ /** 成功出站的 OTLP 请求计数(traces/metrics 分批后按请求计) */
50
77
  tracesExported: number
51
78
  metricsExported: number
52
79
  droppedExports: number
80
+ /** 1.2-S3 加法:buffer 溢出丢最旧计数(maxQueueSize 上限) */
81
+ queueOverflows: number
53
82
  }
54
83
 
55
84
  export interface TelemetryFacade {
@@ -76,6 +105,33 @@ const noopSpan = (): TelemetrySpan => ({
76
105
  })
77
106
  const NOOP_TRACER: TelemetryTracer = { startSpan: () => noopSpan() }
78
107
  const NOOP_METER: TelemetryMeter = { createCounter: () => ({ add() {} }) }
108
+ const NOOP_STATS: TelemetryStats = { spansEnded: 0, spansSampledOut: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0, queueOverflows: 0 }
109
+
110
+ /**
111
+ * 1.2-S3 env 配置通道:CAR_OTEL_ENDPOINT(唯一开关)/ CAR_OTEL_SAMPLING(always_on|always_off|0..1,
112
+ * 非法值保持缺省 always_on——遥测面禁 fail-hard)/ CAR_OTEL_INTERVAL_MS / CAR_OTEL_SERVICE_NAME。
113
+ * 无 endpoint = null(默认关,noop 门面)。
114
+ */
115
+ export function telemetryConfigFromEnv(env: NodeJS.ProcessEnv = process.env): TelemetryConfig | null {
116
+ const endpoint = env.CAR_OTEL_ENDPOINT
117
+ if (!endpoint) return null
118
+ const cfg: TelemetryConfig = { endpoint }
119
+ if (env.CAR_OTEL_SERVICE_NAME) cfg.serviceName = env.CAR_OTEL_SERVICE_NAME
120
+ if (env.CAR_OTEL_INTERVAL_MS) {
121
+ const n = Number(env.CAR_OTEL_INTERVAL_MS)
122
+ if (Number.isFinite(n) && n >= 0) cfg.intervalMs = n
123
+ }
124
+ if (env.CAR_OTEL_SAMPLING) {
125
+ const s = env.CAR_OTEL_SAMPLING
126
+ if (s === 'always_on' || s === 'always_off') cfg.sampling = s
127
+ else {
128
+ const r = Number(s)
129
+ if (Number.isFinite(r) && r >= 0 && r <= 1) cfg.sampling = { ratio: r }
130
+ // 非法采样值:保持缺省 always_on(登记口径,禁 fail-hard)
131
+ }
132
+ }
133
+ return cfg
134
+ }
79
135
 
80
136
  // ==================== 活动门面(显式开路径) ====================
81
137
 
@@ -104,36 +160,61 @@ export function createTelemetryFacade(
104
160
  getMeter: () => NOOP_METER,
105
161
  flush: async () => {},
106
162
  shutdown: async () => {},
107
- stats: () => ({ spansEnded: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0 }),
163
+ stats: () => ({ ...NOOP_STATS }),
108
164
  }
109
165
  }
110
166
 
111
167
  const fetchImpl = opts.fetchImpl ?? fetch
112
168
  const now = opts.now ?? Date.now
169
+ const sleep = config?.sleep ?? (ms => new Promise<void>(r => setTimeout(r, ms)))
113
170
  const timeoutMs = config?.exportTimeoutMs ?? 5_000
171
+ const retryLimit = config?.retryLimit ?? 2
172
+ const backoff = config?.retryBackoffMs ?? [1_000, 2_000]
173
+ const sampling = config?.sampling ?? 'always_on'
174
+ const maxBatchSize = config?.maxBatchSize ?? 512
175
+ const maxQueueSize = config?.maxQueueSize ?? 2_048
114
176
  const baseHeaders = { 'content-type': 'application/json', ...(config?.headers ?? {}) }
115
177
  const serviceName = config?.serviceName ?? 'car-runtime'
116
178
 
117
179
  const spans: SpanRecord[] = []
118
- const counters = new Map<string, { labels: Record<string, string>; value: number }>()
119
- const stats: TelemetryStats = { spansEnded: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0 }
180
+ // 计数器键 = name+labels JSON(聚合去重);记录体分离保存 name/labels——
181
+ // OTLP metric 名必须是纯 name(1.2-BUG-3:键直当名会把 labels JSON 拼进 metric 名,
182
+ // M8 最小实现即存在,meter 零生产调用点掩盖至今,1.2-S3 meter 桥接入时暴露)
183
+ const counters = new Map<string, { name: string; labels: Record<string, string>; value: number }>()
184
+ const stats: TelemetryStats = { spansEnded: 0, spansSampledOut: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0, queueOverflows: 0 }
120
185
  let tracer: TelemetryTracer | null = null
121
186
  let meter: TelemetryMeter | null = null
122
187
  let timer: ReturnType<typeof setInterval> | null = null
123
188
  let flushing = false
124
189
 
190
+ /**
191
+ * OTLP 出站(D-12b 口径:有界重试)——ok / 429/5xx/网络错误退避重试 ≤retryLimit /
192
+ * 4xx 业务错不重试(端点配置类问题,重试无益);重试耗尽返回 false 由调用方计入
193
+ * droppedExports(静默丢弃,永不抛错——BD-05「不抛错」收紧保持)。
194
+ */
125
195
  const exportOtlp = async (path: string, body: unknown): Promise<boolean> => {
126
- try {
127
- // 单次尝试 + 有界超时(§3.5.4:OTel 导出 5s / 0 重试);失败静默丢弃(BD-05)
128
- await fetchImpl(endpoint + path, {
129
- method: 'POST',
130
- headers: baseHeaders,
131
- body: JSON.stringify(body),
132
- signal: AbortSignal.timeout(timeoutMs),
133
- })
134
- return true
135
- } catch {
136
- return false
196
+ for (let attempt = 0; ; attempt++) {
197
+ try {
198
+ const res = await fetchImpl(endpoint + path, {
199
+ method: 'POST',
200
+ headers: baseHeaders,
201
+ body: JSON.stringify(body),
202
+ signal: AbortSignal.timeout(timeoutMs),
203
+ })
204
+ if (res.ok) return true
205
+ if ((res.status === 429 || res.status >= 500) && attempt < retryLimit) {
206
+ await sleep(backoff[Math.min(attempt, backoff.length - 1)]!)
207
+ continue
208
+ }
209
+ return false
210
+ } catch {
211
+ // 网络错误/超时:可重试类
212
+ if (attempt < retryLimit) {
213
+ await sleep(backoff[Math.min(attempt, backoff.length - 1)]!)
214
+ continue
215
+ }
216
+ return false
217
+ }
137
218
  }
138
219
  }
139
220
 
@@ -141,13 +222,15 @@ export function createTelemetryFacade(
141
222
  if (flushing) return
142
223
  flushing = true
143
224
  try {
144
- const spanBatch = spans.splice(0, spans.length)
145
- if (spanBatch.length) {
225
+ const spanQueue = spans.splice(0, spans.length)
226
+ // maxBatchSize 分批(1.2-S3):tracesExported 按成功出站请求计数
227
+ for (let i = 0; i < spanQueue.length; i += maxBatchSize) {
228
+ const batch = spanQueue.slice(i, i + maxBatchSize)
146
229
  const body = {
147
230
  resource: { attributes: [{ key: 'service.name', value: { stringValue: serviceName } }] },
148
231
  scopeSpans: [{
149
232
  scope: { name: 'car-runtime' },
150
- spans: spanBatch.map(s => ({
233
+ spans: batch.map(s => ({
151
234
  traceId: s.traceId,
152
235
  spanId: s.spanId,
153
236
  ...(s.parentSpanId ? { parentSpanId: s.parentSpanId } : {}),
@@ -170,8 +253,8 @@ export function createTelemetryFacade(
170
253
  resource: { attributes: [{ key: 'service.name', value: { stringValue: serviceName } }] },
171
254
  scopeMetrics: [{
172
255
  scope: { name: 'car-runtime' },
173
- metrics: batch.map(([name, c]) => ({
174
- name,
256
+ metrics: batch.map(([, c]) => ({
257
+ name: c.name,
175
258
  sum: {
176
259
  aggregationTemporality: 'AGGREGATION_TEMPORALITY_CUMULATIVE',
177
260
  dataPoints: Object.entries(c.labels).length
@@ -209,8 +292,15 @@ export function createTelemetryFacade(
209
292
  recordException(err) { record.attributes['exception.message'] = String((err as Error)?.message ?? err); return this },
210
293
  end() {
211
294
  record.endMs = now()
212
- spans.push(record)
213
295
  stats.spansEnded++
296
+ // head 采样于 end() 决策(1.2-S3):缺省 always_on 全量(1.1 行为不变);采样只减少出站
297
+ const keep = sampling === 'always_on' ? true
298
+ : sampling === 'always_off' ? false
299
+ : Math.random() < sampling.ratio
300
+ if (!keep) { stats.spansSampledOut++; return }
301
+ // buffer 上限:溢出丢最旧 + 计数(内存有界)
302
+ if (spans.length >= maxQueueSize) { spans.shift(); stats.queueOverflows++ }
303
+ spans.push(record)
214
304
  },
215
305
  }
216
306
  },
@@ -221,7 +311,7 @@ export function createTelemetryFacade(
221
311
  return {
222
312
  add(value, labels = {}) {
223
313
  const key = name + JSON.stringify(labels)
224
- const cur = counters.get(key) ?? { labels, value: 0 }
314
+ const cur = counters.get(key) ?? { name, labels, value: 0 }
225
315
  cur.value += value
226
316
  counters.set(key, cur)
227
317
  },
@@ -8,8 +8,8 @@
8
8
  * 索引只收录链完整会话(审计面口径:可索引 = 可验证);
9
9
  * - 物理编码 layout-blind:plain / zstd 统一经 format.ts 装载,索引记录 encoding 供检索。
10
10
  */
11
- import { readdirSync } from 'node:fs'
12
- import { join } from 'node:path'
11
+ import { readdirSync, existsSync, realpathSync } from 'node:fs'
12
+ import { join, resolve } from 'node:path'
13
13
  import { loadSessionLog, type SessionEvent } from './log.ts'
14
14
 
15
15
  export interface SessionIndexRow {
@@ -31,14 +31,189 @@ export interface RebuildResult {
31
31
  }
32
32
 
33
33
  type SqliteValue = string | number | null
34
- interface SqliteStmt { run(...v: SqliteValue[]): unknown }
34
+ interface SqliteStmt { run(...v: SqliteValue[]): unknown; get(...v: SqliteValue[]): unknown }
35
35
  interface SqliteDb { exec(sql: string): void; prepare(sql: string): SqliteStmt; close(): void }
36
36
 
37
+ const CAR_E_SQLITE = 'CAR-E-SQLITE: 当前 Node 无 node:sqlite 能力(22.5+ 需 --experimental-sqlite,23.4+ 原生)——显式拒绝,不静默降级'
38
+
37
39
  /** node:sqlite 能力探测(模块存在性;静态 import 在无能力 Node 上会崩模块加载,故动态) */
38
40
  export async function sqliteAvailable(): Promise<boolean> {
39
41
  try { await import('node:sqlite'); return true } catch { return false }
40
42
  }
41
43
 
44
+ /** 打开索引库(db 文件必须已存在——lookup 不产生建文件副作用;open 失败 = 索引文件无效显式报错) */
45
+ async function openExistingDb(dbPath: string): Promise<SqliteDb> {
46
+ if (!existsSync(dbPath)) {
47
+ throw new Error(`CAR-E-INDEX: 会话索引不存在:${dbPath}——先执行 car session rebuild-index 建立索引`)
48
+ }
49
+ const mod = await import('node:sqlite').catch(() => null)
50
+ if (!mod) throw new Error(CAR_E_SQLITE)
51
+ const DatabaseSync = (mod as unknown as { DatabaseSync: new (path: string) => SqliteDb }).DatabaseSync
52
+ try {
53
+ return new DatabaseSync(dbPath)
54
+ } catch (e) {
55
+ throw new Error(`CAR-E-INDEX: 索引文件无效(打开失败:${(e as Error).message}):${dbPath}`)
56
+ }
57
+ }
58
+
59
+ /** 单会话检索(1.2-S1 W3-1):--session <id> 经索引解析 sessionId → 文件;未收录 = null(「可索引 = 可验证」口径——断链会话不入索引) */
60
+ export async function lookupSession(dbPath: string, sessionId: string): Promise<SessionIndexRow | null> {
61
+ const db = await openExistingDb(dbPath)
62
+ try {
63
+ const table = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'").get()
64
+ if (!table) throw new Error(`CAR-E-INDEX: 索引文件无效(无 sessions 表):${dbPath}`)
65
+ const row = db.prepare('SELECT * FROM sessions WHERE sessionId = ?').get(sessionId) as Record<string, SqliteValue> | undefined
66
+ if (!row) return null
67
+ return {
68
+ sessionId: String(row.sessionId),
69
+ file: String(row.file),
70
+ eventCount: Number(row.eventCount),
71
+ chainHead: row.chainHead == null ? null : String(row.chainHead),
72
+ chainTail: row.chainTail == null ? null : String(row.chainTail),
73
+ firstTs: row.firstTs == null ? null : Number(row.firstTs),
74
+ lastTs: row.lastTs == null ? null : Number(row.lastTs),
75
+ tornTail: Number(row.tornTail) === 1,
76
+ encoding: row.encoding === 'zstd' ? 'zstd' : 'plain',
77
+ }
78
+ } catch (e) {
79
+ const msg = (e as Error).message
80
+ if (msg.startsWith('CAR-E-')) throw e // 本层显式错误原样透传
81
+ // SQLite 惰性校验:非索引文件 open 不报错、query 才报(file is not a database)——统一收口为显式口径
82
+ throw new Error(`CAR-E-INDEX: 索引文件无效(${msg}):${dbPath}`)
83
+ } finally {
84
+ db.close()
85
+ }
86
+ }
87
+
88
+ const CREATE_SESSIONS_SQL = `CREATE TABLE IF NOT EXISTS sessions (
89
+ sessionId TEXT PRIMARY KEY,
90
+ file TEXT NOT NULL,
91
+ eventCount INTEGER NOT NULL,
92
+ chainHead TEXT,
93
+ chainTail TEXT,
94
+ firstTs INTEGER,
95
+ lastTs INTEGER,
96
+ tornTail INTEGER NOT NULL DEFAULT 0,
97
+ encoding TEXT NOT NULL DEFAULT 'plain',
98
+ rebuiltAt TEXT NOT NULL
99
+ )`
100
+
101
+ /** 单行增量 upsert(1.2-S2 W3-2「更新半边」):库/表不存在则自举创建(活会话无需先 rebuild 即可入索引) */
102
+ export async function upsertSessionRow(dbPath: string, row: SessionIndexRow): Promise<void> {
103
+ const mod = await import('node:sqlite').catch(() => null)
104
+ if (!mod) throw new Error(CAR_E_SQLITE)
105
+ const DatabaseSync = (mod as unknown as { DatabaseSync: new (path: string) => SqliteDb }).DatabaseSync
106
+ try {
107
+ const db = new DatabaseSync(dbPath)
108
+ try {
109
+ db.exec(CREATE_SESSIONS_SQL)
110
+ db.prepare(
111
+ 'INSERT OR REPLACE INTO sessions (sessionId, file, eventCount, chainHead, chainTail, firstTs, lastTs, tornTail, encoding, rebuiltAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
112
+ ).run(row.sessionId, row.file, row.eventCount, row.chainHead, row.chainTail, row.firstTs, row.lastTs, row.tornTail ? 1 : 0, row.encoding, new Date().toISOString())
113
+ } finally {
114
+ db.close()
115
+ }
116
+ } catch (e) {
117
+ const msg = (e as Error).message
118
+ if (msg.startsWith('CAR-E-')) throw e
119
+ throw new Error(`CAR-E-INDEX: 索引更新失败(${msg}):${dbPath}`)
120
+ }
121
+ }
122
+
123
+ /**
124
+ * 运行时索引更新器(1.2-S2 W3-2):file-backed 会话 append 后的异步增量通道。
125
+ *
126
+ * 口径(M7 §4.2 登记 + 1.2 规划 R-2):
127
+ * - 按行增量:SessionLog.append 的 sink 先于事件入内存调用(fail-fast 语义),故 record
128
+ * 直接收行 JSON——seq/ts/hash 取自行数据(seq 权威:eventCount = seq+1),不读内存快照;
129
+ * - debounce 合并:窗口内多次 append 只落一次 upsert(行状态由最新 append 覆盖);
130
+ * - 失败非致命:upsert 任何错误经 onError 留痕并计数,绝不向 append 路径传播——
131
+ * 索引是派生缓存,丢失由 rebuild-index 兜底(语义自洽,M7 已登记);
132
+ * - SQLite 能力缺席:首次 flush 显式登记一次后停用(非静默,后续 record 不再调度);
133
+ * - 活会话行口径:tornTail=false(按构造完整)、encoding='plain'(zstd 定位归档/导出,M7 W3)。
134
+ */
135
+ interface PendingState {
136
+ sessionId: string
137
+ file: string
138
+ eventCount: number
139
+ chainHead: string
140
+ chainTail: string
141
+ firstTs: number
142
+ lastTs: number
143
+ }
144
+
145
+ export class IndexUpdater {
146
+ readonly #dbPath: string
147
+ readonly #debounceMs: number
148
+ readonly #onError: (e: Error) => void
149
+ #state: PendingState | null = null
150
+ #timer: ReturnType<typeof setTimeout> | null = null
151
+ #capabilityMissing = false
152
+ #updates = 0
153
+ #failures = 0
154
+
155
+ constructor(opts: { dbPath: string; debounceMs?: number; onError?: (e: Error) => void }) {
156
+ this.#dbPath = opts.dbPath
157
+ this.#debounceMs = opts.debounceMs ?? 2_000
158
+ this.#onError = opts.onError ?? (e => { try { console.error(`[index-updater] ${e.message}`) } catch { /* 收口期 stderr 不可用 */ } })
159
+ }
160
+
161
+ get stats(): { updates: number; failures: number } {
162
+ return { updates: this.#updates, failures: this.#failures }
163
+ }
164
+
165
+ /** append sink 链上调用(行 JSON):seq 权威增量;非预期行防御性忽略 */
166
+ record(sessionId: string, line: string, file: string): void {
167
+ if (this.#capabilityMissing) return
168
+ let e: { seq?: unknown; ts?: unknown; hash?: unknown }
169
+ try { e = JSON.parse(line) as { seq?: unknown; ts?: unknown; hash?: unknown } } catch { return }
170
+ const seq = Number(e.seq)
171
+ const ts = Number(e.ts)
172
+ const hash = typeof e.hash === 'string' ? e.hash : ''
173
+ if (!Number.isFinite(seq) || !Number.isFinite(ts) || !hash) return
174
+ // realpath 规范化(1.2-BUG-4):macOS tmpdir 为 symlink(/var → /private/var),子进程 cwd 由
175
+ // OS 返回物理路径——resolve() 不解析符号链接,父子进程会产出同一文件的两个别名形态;
176
+ // 入库统一为 realpath(文件刚被 append 过必存在;异常回退 resolve)
177
+ const canonical = (() => {
178
+ try { return realpathSync(file) } catch { return resolve(file) }
179
+ })()
180
+ const s = this.#state
181
+ if (!s || s.sessionId !== sessionId || s.file !== canonical) {
182
+ this.#state = { sessionId, file: canonical, eventCount: seq + 1, chainHead: hash, chainTail: hash, firstTs: ts, lastTs: ts }
183
+ } else {
184
+ s.eventCount = Math.max(s.eventCount, seq + 1)
185
+ s.chainTail = hash
186
+ s.lastTs = ts
187
+ }
188
+ this.#timer ??= setTimeout(() => { void this.flush() }, this.#debounceMs)
189
+ }
190
+
191
+ /** 立即落 pending(close/exit 时点调用;幂等——无 pending 即空转) */
192
+ async flush(): Promise<void> {
193
+ if (this.#timer) { clearTimeout(this.#timer); this.#timer = null }
194
+ if (!this.#state) return
195
+ const s = this.#state
196
+ const row: SessionIndexRow = { ...s, tornTail: false, encoding: 'plain' }
197
+ try {
198
+ await upsertSessionRow(this.#dbPath, row)
199
+ this.#updates++
200
+ } catch (e) {
201
+ this.#failures++
202
+ const msg = (e as Error).message
203
+ if (msg.startsWith('CAR-E-SQLITE')) {
204
+ // 能力缺席:登记一次后停用(后续 record 不再调度——禁每窗口重复刷屏)
205
+ this.#capabilityMissing = true
206
+ }
207
+ this.#onError(e as Error)
208
+ }
209
+ }
210
+
211
+ /** 收口:取消未决 timer + 落 pending(car run 关句柄前调用) */
212
+ async close(): Promise<void> {
213
+ await this.flush()
214
+ }
215
+ }
216
+
42
217
  function scanLogFiles(root: string): string[] {
43
218
  const rels = readdirSync(root, { recursive: true }) as string[]
44
219
  return rels
@@ -198,7 +198,8 @@ test('M8: 遥测默认关——noop 句柄 + 零出站 + 零积累(可机器
198
198
  t.getMeter().createCounter('c').add(1)
199
199
  await t.flush()
200
200
  await t.shutdown()
201
- assert.deepEqual(t.stats(), { spansEnded: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0 })
201
+ // 1.2-S3 stats 加法字段(spansSampledOut/queueOverflows)——noop 全零意图不变
202
+ assert.deepEqual(t.stats(), { spansEnded: 0, spansSampledOut: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0, queueOverflows: 0 })
202
203
  })
203
204
 
204
205
  test('M8: 遥测显式开——OTLP/HTTP 形状出站 + stats + 端点不可达静默丢弃(BD-05)', async () => {