@lqc123qwe/car-runtime 1.7.0 → 1.8.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.
- package/README.md +13 -1
- package/package.json +1 -1
- package/src/cli.ts +28 -5
- package/src/host/stdio.ts +8 -2
- package/src/mcp/gateway.ts +10 -2
- package/src/ptc/runCode.ts +24 -4
- package/src/ptc/worker-entry.ts +8 -2
- package/src/runtime-core/llm.ts +49 -2
- package/src/runtime-core/telemetry.ts +12 -4
- package/src/runtime-core/trace.ts +100 -4
- package/test/s42-traceparent-propagation.spec.ts +415 -0
package/README.md
CHANGED
|
@@ -226,9 +226,21 @@ node --experimental-transform-types --test test/*.spec.ts
|
|
|
226
226
|
- **1.6 工程收口(2026-09-28,`6e0315d`)**:候选池三块全部兑现(1.4/1.5 冻结表「登记后续」全数出清)。全量 **318(315 PASS + 3 能力门控 skip / 0 fail)** + tsc strict 0 + secrets-scan commit 后复跑 117 文件 0 命中 + contract-check 4/4(golden 不动)。**1.6-插曲-1(stdio 响应乱序)为 spawn E2E 按序断言直接产出**;**1.6-BUG-1(jiti interopDefault 缺省 true)被 s35 零改动验收线捕获**。
|
|
227
227
|
- **1.6.0 正式发布(2026-09-28,S5 收官)**:npm `@lqc123qwe/car-runtime@1.6.0` 在架——**dist-tags latest → 1.6.0、rc 保留 1.0.0-rc.1** + provenance。Release `1.6.0`(id 398135305,agent GCM 凭据 API 创建)→ **j16 run 36408091143 全步 success**(构建/keyless 签名/自动上传五件套/npm publish)→ **post-publish 13 PASS / 0 DRY-RUN / 0 FAIL(REMOTE_CHECK=1 全实查)**;外部等价检查 **SHA256 MATCH(`dadc1a3c…`)+ Rekor 交叉验证**(logIndex 2981799459 双端一致 + 透明日志 body 内制品哈希三方一致——tree 键形态差异登记见收口报告 §5)。
|
|
228
228
|
- **遗留登记(1.7 候选池)**:遥测 OTLP 增强(两度延后);MCP legacy HTTP+SSE 维持不追溯;写类工具人审 confirm 通道宿主面外推(D-25 口径);插件 jiti 轨收敛观察项(插件仍走原生剥离,可选演进非缺陷)。规划:`car-docs/10-内核v1/1.6-迭代规划.md`;收口报告:`car-docs/10-内核v1/1.6-收口终验报告.md`。
|
|
229
|
+
|
|
230
|
+
## 1.7 状态(OTLP span 语义收口 + metrics 时序修正 + mcp-serve span 面)
|
|
231
|
+
|
|
232
|
+
| `src/runtime-core/telemetry.ts` | 1.7-S1(D-26)**OTLP span 语义标准化**:recordException → OTel 规范形态(span event name='exception',exception.type/exception.message 事件属性 + timeUnixNano)+ span status → STATUS_CODE_ERROR——替换 1.1 起「仅 exception.message 属性 + 恒 UNSET」非标准形态;**stacktrace 缺省不出站**(防路径/源码面外泄——隐私三原则正向收紧,可选开关不随本迭代引入);addEvent → 标准 events 数组编码(name + 原形态属性 + 事件时刻——替换 `event.<name>` JSON 属性串,零生产调用点依赖旧编码实证);出站体 spans[] 加法字段 events/status(无事件 span 不出 events 键——空形态合法);正常 span 恒 STATUS_CODE_UNSET 行为不变 | 1.4 遗留「OTLP 增强」出清主体 |
|
|
233
|
+
| `src/runtime-core/telemetry.ts` | 1.7-S2(D-27)**metrics CUMULATIVE 时序修正**:flush 去 counters.clear()——每次导出全量累计快照(真 CUMULATIVE);1.2-S3 起 clear() 与 CUMULATIVE 标注矛盾出清(intervalMs>0 自动导出时下游把增量解读为总量回落;缺省手动单次 flush 无害——潜伏缺陷面);内存有界由 label 基数保证(零内容枚举红线:AllowedLabels 3 计数器);DELTA 备选不采纳(生态主流 cumulative + S17 计数语义即累计) | 1.2-BUG-3 同源缝隙出清 |
|
|
234
|
+
| `src/runtime-core/trace.ts` + `src/cli.ts` | 1.7-S3(D-28)**mcp-serve turn span 面**:sessionTurn 于装配层拦截(1.4-S6 纪律同款)挂 car.turn span——facade 内部自增派生 turnId(T{n})调用前不可知,**TurnSpanHandle 加法 setTurnId 事后回填**(startTurn('') 空值不设占位属性;car run 直传路径不受影响);outcome = 归一化 turnEnd reason;handle 层 `{ok:false}` 吞错路径 span 以 error 收口 + exception 事件(D-26 形态在 turn 级成立);**不挂 step span**(mcp-serve 无 model() 循环——面外推登记不适用);离线运维工具(verify/replay/export)无 span 属口径 | span 生产覆盖出清(car run turn+step + mcp-serve turn) |
|
|
235
|
+
| `test/s39~s41` | s39 5 测(exception 事件形态+status ERROR/非 Error 兜底/addEvent 标准编码/无事件不出键/多事件保序)+ s40 3 测(双 flush 累计递增 5→7/无新增同值重出/多计数器独立累计)+ s41 2 测(spawn E2E turn span 出站 turnId=T1 回填+零内容断言/未知会话 error 收口+exception 事件) | 1.7-GO |
|
|
236
|
+
|
|
237
|
+
- **1.7 工程收口(2026-09-28,`5e39275`)**:1.4 遗留「遥测 OTLP 增强」(三度登记)经现状盘点拆解三块全部兑现(重试/采样/批上限/env 已随 1.2-S3、turn/step span 已随 1.4-S6——剩余缺口为语义正确性与覆盖面)。全量 **328(325 PASS + 3 能力门控 skip / 0 fail)** + tsc strict 0 + secrets-scan 117 文件 0 命中 + contract-check 4/4(golden 不动)+ 零依赖静态断言保持。**1.7-插曲-1(TurnSpanHandle 缺 recordException——mcp-serve 失败路径 TypeError)为 s41 失败路径断言直接产出**;存量断言零改动(s29/s32 无出站体形态钉死——复核实证)。
|
|
238
|
+
- **1.7.0 正式发布(2026-09-28,S5 收官)**:npm `@lqc123qwe/car-runtime@1.7.0` 在架——**dist-tags latest → 1.7.0、rc 保留 1.0.0-rc.1** + provenance。Release `1.7.0`(id 398173870,agent GCM 凭据 API 创建)→ **j16 run 36414031672 全步 success**(构建/keyless 签名/自动上传五件套/npm publish)→ **post-publish 13 PASS / 0 DRY-RUN / 0 FAIL(REMOTE_CHECK=1 全实查)**;外部等价检查 **SHA256 MATCH(`24bb6c40…`)+ Rekor 交叉验证**(logIndex 2981897662 双端一致 + integratedTime 1790593823 + 透明日志 body 内制品哈希三方一致——tree 键形态差异延续 1.6 登记口径,详见收口报告 §5)。
|
|
239
|
+
- **遗留登记(1.8 候选池)**:W3C traceparent 跨进程传播(MCP stdio JSON-RPC 两端 + PTC worker 桥挂链——协议两端协同面大,1.7 延后登记);MCP legacy HTTP+SSE 维持不追溯(D-21);写类工具人审 confirm 通道宿主面外推(D-25 口径——前置未满足);插件 jiti 轨收敛观察项(可选演进非缺陷)。规划:`car-docs/10-内核v1/1.7-迭代规划.md`;收口报告:`car-docs/10-内核v1/1.7-收口终验报告.md`。
|
|
240
|
+
|
|
229
241
|
## 状态
|
|
230
242
|
|
|
231
|
-
- **版本主线**:S1-S4 48/48 | M2 89/89(v0.2.0)| M3 127/127(v0.3.0)| M4 139/139(**1.0.0-rc.1 已发布**)| M5 156/156(DEC-4 预埋项转正)| M6 179/179(2026-09-15 收口)| M7 全量 195(192 PASS + 3 能力门控 skip,2026-09-19 收口)| **M5-S28 装载签名门 全量 204(201 PASS + 3 skip,2026-09-26)** | **M8 运行时底座 全量 231(228 PASS + 3 skip,2026-09-26 收口,§3.2 八模块全部收口)** | **1.1 签名收尾最小集 全量 249(246 PASS + 3 skip,2026-09-27 收口,1.1.0 已发布,enforce 翻转准备就绪)** | **1.2 会话/遥测小项 全量 263(260 PASS + 3 skip,2026-09-27 收口,1.2.0 已发布)** | **1.3 发布资产自动化 全量 270(267 PASS + 3 skip,2026-09-27 收口,1.3.0 已发布,全链零人工拖拽兑现)** | **1.4 真实模型接入 全量 282(279 PASS + 3 skip,2026-09-27 收口,1.4.0 已发布)** | **1.5 provider 扩展+远程 MCP+配置完整形态 全量 300(297 PASS + 3 skip,2026-09-28 收口,1.5.0 已发布)** | **1.6 宿主工具面+投影收敛+jiti 载体 全量 318(315 PASS + 3 skip,2026-09-28 收口,1.6.0 已发布)**
|
|
243
|
+
- **版本主线**:S1-S4 48/48 | M2 89/89(v0.2.0)| M3 127/127(v0.3.0)| M4 139/139(**1.0.0-rc.1 已发布**)| M5 156/156(DEC-4 预埋项转正)| M6 179/179(2026-09-15 收口)| M7 全量 195(192 PASS + 3 能力门控 skip,2026-09-19 收口)| **M5-S28 装载签名门 全量 204(201 PASS + 3 skip,2026-09-26)** | **M8 运行时底座 全量 231(228 PASS + 3 skip,2026-09-26 收口,§3.2 八模块全部收口)** | **1.1 签名收尾最小集 全量 249(246 PASS + 3 skip,2026-09-27 收口,1.1.0 已发布,enforce 翻转准备就绪)** | **1.2 会话/遥测小项 全量 263(260 PASS + 3 skip,2026-09-27 收口,1.2.0 已发布)** | **1.3 发布资产自动化 全量 270(267 PASS + 3 skip,2026-09-27 收口,1.3.0 已发布,全链零人工拖拽兑现)** | **1.4 真实模型接入 全量 282(279 PASS + 3 skip,2026-09-27 收口,1.4.0 已发布)** | **1.5 provider 扩展+远程 MCP+配置完整形态 全量 300(297 PASS + 3 skip,2026-09-28 收口,1.5.0 已发布)** | **1.6 宿主工具面+投影收敛+jiti 载体 全量 318(315 PASS + 3 skip,2026-09-28 收口,1.6.0 已发布)** | **1.7 OTLP span 语义收口+metrics 时序修正+mcp-serve span 面 全量 328(325 PASS + 3 skip,2026-09-28 收口,1.7.0 已发布)**
|
|
232
244
|
- **1.0.0 正式发布(2026-09-26,S29/S30)**:npm `@lqc123qwe/car-runtime@1.0.0` 在架——**dist-tag latest → 1.0.0,rc 保留指向 1.0.0-rc.1**(§1.1 口径);SLSA provenance(Actions OIDC,透明日志在案);GitHub Release v1.0.0 六资产(tgz/bundle/SHA256SUMS/SBOM/审计/公钥);**post-publish 全链 13 PASS / 0 DRY-RUN / 0 FAIL** + 外部验证者等价检查(Release 实下载件 SHA256 MATCH + cosign Verified OK)。**宽限期时钟:2026-09-26 起算,2026-12-25 到期 verifier 默认值 warn→enforce 翻转(计划内变更)**。终验报告:`car-docs/10-内核v1/M5-S30-1.0收口终验报告.md`。发布工程事件:j16 首跑 E403(npm 2FA 策略 vs granular token 无 bypass——清单预设回退路径兑现)→ bypass token re-run 绿;G-09 期望 tag 动态化(`4c84b17`)在真实发布兑现价值。
|
|
233
245
|
- **1.0.0-rc.1 已发布(2026-09-09)**:npm `@lqc123qwe/car-runtime@1.0.0-rc.1`(rc + latest 双 tag,SLSA provenance 在案)+ GitHub Release 6 制品;发布预演 **13 PASS / 0 DRY-RUN / 0 FAIL**(c762b72)。S22 GO 清单已全绿收口。
|
|
234
246
|
- **轨道 A 三项全部完成**:E-1 WSL2 逃逸矩阵 **20/20 PASS 零逃逸**(2026-09-07,G-07 转 PASS)| E-3 远端发布通道 **13/13 全门禁 PASS**(2026-09-09,G-08/09/10 转 PASS)| E-2 Windows koffi FFI **四判据双环境全 PASS**(S24/S25/S26,2026-09-09~10;本机非提权 + windows-latest 提权 runner 双绿,CI 门禁已接入)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lqc123qwe/car-runtime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Composable Agent Runtime (CAR) - microkernel plugin system with sandboxed PTC, append-only hashed session log, and MCP host bridge",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/cli.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { CredentialService } from './runtime-core/credentials.ts'
|
|
|
32
32
|
import { chatStep } from './runtime-core/chatStep.ts'
|
|
33
33
|
import { toToolDefinitions } from './runtime-core/tools.ts'
|
|
34
34
|
import { createTelemetryFacade, telemetryConfigFromEnv } from './runtime-core/telemetry.ts'
|
|
35
|
-
import { TurnTracer } from './runtime-core/trace.ts'
|
|
35
|
+
import { TurnTracer, parseTraceparent, type TurnSpanHandle } from './runtime-core/trace.ts'
|
|
36
36
|
import type { CounterName, AllowedLabels } from './telemetry/metrics.ts'
|
|
37
37
|
|
|
38
38
|
const HELP = `用法: car <command> [args]
|
|
@@ -216,6 +216,8 @@ async function main(): Promise<number> {
|
|
|
216
216
|
}), { default: true })
|
|
217
217
|
// MCP 工具进 turn(D-18):--mcp <serverId>(可重复;server 启动规格在 car.config.json mcp.servers 登记)
|
|
218
218
|
const mcpIds: string[] = []
|
|
219
|
+
// 1.8-D29:活跃 turn span 句柄(startTurn 后赋值——MCP 工具闭包运行期取 client span 挂链面)
|
|
220
|
+
let activeTurn: TurnSpanHandle | null = null
|
|
219
221
|
for (let i = 0; i < rest.length; i++) {
|
|
220
222
|
if (rest[i] === '--mcp') {
|
|
221
223
|
const id = rest[++i]
|
|
@@ -243,7 +245,22 @@ async function main(): Promise<number> {
|
|
|
243
245
|
if (toolsMap.has(modelName)) { console.error(`CAR-E-MCP: 工具名冲突 ${modelName}(跨源同名禁静默覆盖)`); return 1 }
|
|
244
246
|
toolsMap.set(modelName, {
|
|
245
247
|
declaredSideEffect: t.declaredSideEffect ?? 'write',
|
|
246
|
-
|
|
248
|
+
// 1.8-D29:client span(car.mcp.tool 挂 turn)+ traceparent 注入 _meta——
|
|
249
|
+
// noop 门面 traceparent='' → 不注入(线上 JSON-RPC 字节面不变)
|
|
250
|
+
run: async (args: any) => {
|
|
251
|
+
const span = activeTurn?.mcpToolSpan(id, t.name)
|
|
252
|
+
const tp = span?.traceparent ?? ''
|
|
253
|
+
try {
|
|
254
|
+
const r = await gw.callTool(id, t.name, args, tp ? { traceparent: tp } : {})
|
|
255
|
+
if (!r.ok) throw new Error(r.error ?? 'mcp call failed')
|
|
256
|
+
span?.end('ok')
|
|
257
|
+
return r.result
|
|
258
|
+
} catch (e) {
|
|
259
|
+
span?.recordException(e)
|
|
260
|
+
span?.end('error')
|
|
261
|
+
throw e
|
|
262
|
+
}
|
|
263
|
+
},
|
|
247
264
|
...(t.description !== undefined ? { description: t.description } : {}),
|
|
248
265
|
...(t.inputSchema !== undefined ? { parameters: t.inputSchema } : {}),
|
|
249
266
|
})
|
|
@@ -260,7 +277,7 @@ async function main(): Promise<number> {
|
|
|
260
277
|
const { renderSdkFromRegistry } = await import('./ptc/sdk.ts')
|
|
261
278
|
const registry = new Map([...toolsMap].map(([name, t]) => [name, { run: t.run, description: t.description, inputSchema: t.parameters }]))
|
|
262
279
|
sdkBlock = renderSdkFromRegistry(registry)
|
|
263
|
-
const ptc = makePtcToolDefinition({ tools: registry, audit: () => {} })
|
|
280
|
+
const ptc = makePtcToolDefinition({ tools: registry, audit: () => {}, spanBridge: () => activeTurn?.ptcRelay() ?? null })
|
|
264
281
|
toolsMap.set(ptc.name, ptc)
|
|
265
282
|
console.log(`[PTC] run_code 入工具面(桥接 ${registry.size} 工具;SDK 声明块进 system)`)
|
|
266
283
|
}
|
|
@@ -270,6 +287,7 @@ async function main(): Promise<number> {
|
|
|
270
287
|
if (sdkBlock) log.append('runtime', 'system', 'system', sdkBlock)
|
|
271
288
|
log.append('user', 'user', 'T0', prompt)
|
|
272
289
|
const turn = tracer.startTurn('T0')
|
|
290
|
+
activeTurn = turn // 1.8-D29/D-31:MCP 工具闭包 / PTC spanBridge 运行期取挂链面
|
|
273
291
|
let stepNo = 0
|
|
274
292
|
r = await runTurn({
|
|
275
293
|
log, turnId: 'T0',
|
|
@@ -530,9 +548,14 @@ async function main(): Promise<number> {
|
|
|
530
548
|
// 挂点在装配层(1.4-S6 纪律同款);facade 内部自增派生 turnId(T{n})——调用前不可知,
|
|
531
549
|
// 经 setTurnId 回填;outcome = 归一化 turnEnd reason(枚举面零内容)。缺省关 = noop 句柄零出站。
|
|
532
550
|
const tracer = new TurnTracer(otel)
|
|
533
|
-
const server = createStdioServer(async (tool, args) => {
|
|
551
|
+
const server = createStdioServer(async (tool, args, meta) => {
|
|
534
552
|
if (tool !== 'session_turn') return gw.handle(hostId, tool, args)
|
|
535
|
-
|
|
553
|
+
// 1.8-D30:远端挂链——_meta.traceparent 合法 → turn span 加入宿主 trace
|
|
554
|
+
// (traceId 复用 + parentSpanId=远端 spanId;畸形 fail-open 新 trace——遥测禁 fail-hard)
|
|
555
|
+
const parent = otel.enabled && meta?.traceparent ? parseTraceparent(meta.traceparent) : null
|
|
556
|
+
const turn = parent
|
|
557
|
+
? tracer.startTurn('', { trace: { traceId: parent.traceId, parentSpanId: parent.parentSpanId } })
|
|
558
|
+
: tracer.startTurn('')
|
|
536
559
|
try {
|
|
537
560
|
const r = await gw.handle(hostId, tool, args)
|
|
538
561
|
if (!r.ok) {
|
package/src/host/stdio.ts
CHANGED
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
* - 方法面:initialize/notifications 握手(MCP 协议兼容)+ tools/list(10 tool 能力发现)+ tools/call(分发到 HostGateway.handle);
|
|
7
7
|
* - 所有到达请求与响应均经 HostGateway 审计(无旁路);未登记宿主在 handle 层拒绝;
|
|
8
8
|
* - stdin 结束(宿主退出)→ 通道关闭,进程内状态由审计日志承载(BD-02 等价:不静默丢数据)。
|
|
9
|
+
*
|
|
10
|
+
* 1.8(D-30 W3C traceparent server 侧提取):tools/call params._meta 原样透传 dispatch 第三参——
|
|
11
|
+
* 解析/校验归装配层(trace.ts parseTraceparent fail-open),传输层保持无协议语义;未知键对端
|
|
12
|
+
* 忽略(MCP `_meta` 保留扩展点),缺席 = meta 缺省 undefined(既有调用方零改动)。
|
|
9
13
|
*/
|
|
10
14
|
import { createInterface } from 'node:readline'
|
|
11
15
|
import { HOST_TOOLS, type HostTool } from './hostGateway.ts'
|
|
12
16
|
|
|
13
17
|
export interface StdioDispatcher {
|
|
14
|
-
(tool: string, args: Record<string, unknown
|
|
18
|
+
(tool: string, args: Record<string, unknown>, meta?: { traceparent?: string }): Promise<{ ok: boolean; result?: unknown; error?: string }>
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
export interface StdioServer {
|
|
@@ -62,8 +66,10 @@ export function createStdioServer(dispatch: StdioDispatcher): StdioServer {
|
|
|
62
66
|
if (req.method === 'tools/call') {
|
|
63
67
|
const name = req.params?.name ?? ''
|
|
64
68
|
const args = req.params?.arguments ?? {}
|
|
69
|
+
// 1.8-D30:_meta 原样透传(解析归装配层);缺席 = undefined
|
|
70
|
+
const meta = (req.params as { _meta?: { traceparent?: string } } | undefined)?._meta
|
|
65
71
|
callChain = callChain
|
|
66
|
-
.then(() => dispatch(name, args))
|
|
72
|
+
.then(() => dispatch(name, args, meta))
|
|
67
73
|
.then(r => {
|
|
68
74
|
if (r.ok) respond({ content: [{ type: 'text', text: JSON.stringify(r.result ?? {}) }], carOk: true })
|
|
69
75
|
else respond(undefined, { code: -32000, message: r.error ?? 'host call failed' })
|
package/src/mcp/gateway.ts
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* - Server 崩溃/超时 → BD-02 整体标记不可用,不传导进内核主链路(US-6 AC4)
|
|
9
9
|
* - McpServerConfig.env 禁明文密钥(疑似凭据模式即拒绝,sk- 前缀等——§3.2.5 传参禁忌)
|
|
10
10
|
* - 传输抽象:stdio 真实通道(JSON-RPC over 换行分隔 JSON)/ in-process 测试通道
|
|
11
|
+
*
|
|
12
|
+
* 1.8(D-29 W3C traceparent client 侧注入):callTool 加法 opts.traceparent → params._meta
|
|
13
|
+
* (MCP `_meta` 保留扩展点——未知键对端忽略,加法无害);仅 otel 显式开且 client span 存活时
|
|
14
|
+
* 非空(trace.ts mcpToolSpan)——**缺省不注入,JSON-RPC 字节面不变**;tools/list 不注入
|
|
15
|
+
* (注册期无 span 上下文,登记)。解析/校验归 trace.ts(parseTraceparent fail-open),本层原样透传。
|
|
11
16
|
*/
|
|
12
17
|
export interface JsonRpcRequest { jsonrpc: '2.0'; id: number; method: string; params?: unknown }
|
|
13
18
|
export interface JsonRpcResponse { jsonrpc: '2.0'; id: number; result?: unknown; error?: { code: number; message: string } }
|
|
@@ -97,7 +102,7 @@ export class McpGateway {
|
|
|
97
102
|
isAvailable(serverId: string): boolean { return this.#servers.has(serverId) && !this.#unavailable.has(serverId) }
|
|
98
103
|
|
|
99
104
|
/** 调用:走统一权限/审计链路(无旁路);结果回填日志前必经 redact(调用方职责) */
|
|
100
|
-
async callTool(serverId: string, tool: string, args: Record<string, unknown>, opts: { timeoutMs?: number } = {}): Promise<{ ok: boolean; result?: unknown; error?: string }> {
|
|
105
|
+
async callTool(serverId: string, tool: string, args: Record<string, unknown>, opts: { timeoutMs?: number; /** 1.8-D29:W3C traceparent(otel 显式开且 client span 存活时非空;缺省不注入 _meta) */ traceparent?: string } = {}): Promise<{ ok: boolean; result?: unknown; error?: string }> {
|
|
101
106
|
const cfg = this.#servers.get(serverId)
|
|
102
107
|
if (!cfg) throw new Error('CAR-A050001: unregistered MCP connection rejected(登记制)')
|
|
103
108
|
if (this.#unavailable.has(serverId)) {
|
|
@@ -108,7 +113,10 @@ export class McpGateway {
|
|
|
108
113
|
if (!this.#tools.has(key)) return { ok: false, error: `unknown tool "${tool}" on "${serverId}"` }
|
|
109
114
|
try {
|
|
110
115
|
const res = await Promise.race([
|
|
111
|
-
cfg.transport.send({
|
|
116
|
+
cfg.transport.send({
|
|
117
|
+
jsonrpc: '2.0', id: this.#nextId++, method: 'tools/call',
|
|
118
|
+
params: { name: tool, arguments: args, ...(opts.traceparent ? { _meta: { traceparent: opts.traceparent } } : {}) },
|
|
119
|
+
}),
|
|
112
120
|
new Promise<never>((_, rej) => setTimeout(() => rej(new Error('mcp timeout')), opts.timeoutMs ?? 30_000)),
|
|
113
121
|
])
|
|
114
122
|
return { ok: true, result: (res as JsonRpcResponse).result }
|
package/src/ptc/runCode.ts
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* - erasable-only 双挂点之一(本入口);挂点二在 loader 提交链(mountPlugin);
|
|
9
9
|
* - **预算到期不掐 turn**:超限 = 工具错误结果交回模型(TurnEndReason 六值零扩展,ADR-001 兼容);
|
|
10
10
|
* - 并发护栏(自研五维):并发 4 / 子调用超时 30s / 总 worker 存活由本执行器串行门管控。
|
|
11
|
+
*
|
|
12
|
+
* 1.8(D-31 PTC worker 桥挂链):opts.spanBridge 加法(otel 显式开且 turn span 存活时非空)——
|
|
13
|
+
* worker 侧程序计时(Date.now,worker_threads 同进程同时钟域)经 done 消息加法 span 字段回传,
|
|
14
|
+
* 主线程经 bridge.complete() 单点收口 car.ptc span(trace.ts ptcRelay——trace 挂链 turn /
|
|
15
|
+
* facade 时刻覆盖);**单 exporter 纪律:worker 零独立出站通道、零 trace 载荷注入**(worker 无
|
|
16
|
+
* outbound 面——挂链由主线程句柄归因 + 计时 relay 完成)。主侧 killer / worker error 兜底路径
|
|
17
|
+
* 无 done 回传(terminate/崩溃抢先),以主线程时钟收口(口径登记:兜底非 worker 计时)。
|
|
11
18
|
*/
|
|
12
19
|
import { Worker } from 'node:worker_threads'
|
|
13
20
|
import { fileURLToPath } from 'node:url'
|
|
@@ -22,13 +29,19 @@ export interface PtcResult { ok: boolean; result?: unknown; error?: string; wall
|
|
|
22
29
|
|
|
23
30
|
export interface ToolBridge { run(args: unknown): Promise<unknown> }
|
|
24
31
|
|
|
32
|
+
/** 1.8-D31:PTC 桥挂链面(结构兼容 trace.ts PtcRelayHandle——ptc 模块不依赖 runtime-core/trace) */
|
|
33
|
+
export interface PtcSpanBridge {
|
|
34
|
+
complete(startMs: number, endMs: number, outcome: string, error?: unknown): void
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), 'worker-entry.ts')
|
|
26
38
|
const MAX_CONCURRENT = 4
|
|
27
39
|
let active = 0
|
|
28
40
|
|
|
29
41
|
/** PTC 程序执行:每次新 worker;子调用经消息桥回主线程执行工具(宿主权限门生效面) */
|
|
30
42
|
export interface RunCodeOpts { tools: Map<string, ToolBridge>; audit?: (d: Record<string, unknown>) => void
|
|
31
|
-
/** 授权门(S15):返回 false = 拒绝(无 worker 启动,审计留痕);authorizationId 幂等由 authz 服务承载 */ authorize?: (req: PtcRequest) => Promise<boolean>
|
|
43
|
+
/** 授权门(S15):返回 false = 拒绝(无 worker 启动,审计留痕);authorizationId 幂等由 authz 服务承载 */ authorize?: (req: PtcRequest) => Promise<boolean>
|
|
44
|
+
/** 1.8-D31:桥挂链供给(每次 runCode 一次——每程序新 worker;null = 无遥测零 relay) */ spanBridge?: () => { complete(startMs: number, endMs: number, outcome: string, error?: unknown): void } | null }
|
|
32
45
|
|
|
33
46
|
export async function runCode(req: PtcRequest, opts: RunCodeOpts): Promise<PtcResult> {
|
|
34
47
|
// 双必填(description 为授权凭据——S15 授权门消费)
|
|
@@ -51,6 +64,8 @@ export async function runCode(req: PtcRequest, opts: RunCodeOpts): Promise<PtcRe
|
|
|
51
64
|
}
|
|
52
65
|
const started = Date.now()
|
|
53
66
|
active++
|
|
67
|
+
// 1.8-D31:桥挂链供给(每次 runCode 一次);null = 无遥测零 relay
|
|
68
|
+
const relay = opts.spanBridge?.() ?? null
|
|
54
69
|
opts.audit?.({ kind: 'ptc-start', description: req.description, toolCallId: req.toolCallId, budget })
|
|
55
70
|
try {
|
|
56
71
|
return await new Promise<PtcResult>((resolve) => {
|
|
@@ -61,9 +76,11 @@ export async function runCode(req: PtcRequest, opts: RunCodeOpts): Promise<PtcRe
|
|
|
61
76
|
// 主线程侧兜底(worker 内计时器失效时强杀——双层预算)
|
|
62
77
|
const killer = setTimeout(() => {
|
|
63
78
|
void worker.terminate()
|
|
79
|
+
// 1.8-D31:兜底收口(主线程时钟——terminate 抢先无 done relay,口径登记)
|
|
80
|
+
relay?.complete(started, Date.now(), 'error', new Error(`budget-exceeded (maxWallMs=${budget.maxWallMs}, main-side guard)`))
|
|
64
81
|
resolve({ ok: false, error: `budget-exceeded (maxWallMs=${budget.maxWallMs}, main-side guard)`, wallMs: Date.now() - started, outputBytes: 0, budgetExceeded: true })
|
|
65
82
|
}, budget.maxWallMs + 1000)
|
|
66
|
-
worker.on('message', (m: { type: string; callId?: string; name?: string; args?: unknown; result?: unknown; error?: string }) => {
|
|
83
|
+
worker.on('message', (m: { type: string; callId?: string; name?: string; args?: unknown; result?: unknown; error?: string; span?: { startMs: number; endMs: number } }) => {
|
|
67
84
|
if (m.type === 'tool') {
|
|
68
85
|
const def = opts.tools.get(m.name!)
|
|
69
86
|
// 工具异常必须回传为 tool-result error(供程序体 try/catch 恢复)——
|
|
@@ -78,6 +95,8 @@ export async function runCode(req: PtcRequest, opts: RunCodeOpts): Promise<PtcRe
|
|
|
78
95
|
}
|
|
79
96
|
if (m.type === 'done') {
|
|
80
97
|
clearTimeout(killer)
|
|
98
|
+
// 1.8-D31:worker 计时 relay 收口(error 路径同样收口——D-26 exception 形态成立)
|
|
99
|
+
if (m.span && relay) relay.complete(m.span.startMs, m.span.endMs, m.error ? 'error' : 'ok', m.error)
|
|
81
100
|
// F12 出站覆盖:worker 输出回填上下文前强制 redact(M3安全设计增补 T-4)
|
|
82
101
|
let result = m.result
|
|
83
102
|
let secretsRedacted = 0
|
|
@@ -101,7 +120,7 @@ export async function runCode(req: PtcRequest, opts: RunCodeOpts): Promise<PtcRe
|
|
|
101
120
|
})
|
|
102
121
|
}
|
|
103
122
|
})
|
|
104
|
-
worker.on('error', (e) => { clearTimeout(killer); resolve({ ok: false, error: String(e), wallMs: Date.now() - started, outputBytes: 0 }) })
|
|
123
|
+
worker.on('error', (e) => { clearTimeout(killer); relay?.complete(started, Date.now(), 'error', e); resolve({ ok: false, error: String(e), wallMs: Date.now() - started, outputBytes: 0 }) })
|
|
105
124
|
worker.on('exit', (code) => { clearTimeout(killer); active-- })
|
|
106
125
|
// exit 可能先于 done(budget killer)——兜底 resolve 幂等由 Promise 语义保证
|
|
107
126
|
})
|
|
@@ -112,7 +131,8 @@ export async function runCode(req: PtcRequest, opts: RunCodeOpts): Promise<PtcRe
|
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
/** PTC 作为一等 ToolDefinition:强制 write 侧效应(授权门无旁路),接入 runTurn 工具面 */
|
|
115
|
-
export function makePtcToolDefinition(opts: { tools: Map<string, ToolBridge>; audit?: (d: Record<string, unknown>) => void
|
|
134
|
+
export function makePtcToolDefinition(opts: { tools: Map<string, ToolBridge>; audit?: (d: Record<string, unknown>) => void
|
|
135
|
+
/** 1.8-D31:桥挂链供给透传(每次 runCode 一次;缺省 = 无遥测零 relay) */ spanBridge?: () => { complete(startMs: number, endMs: number, outcome: string, error?: unknown): void } | null }) {
|
|
116
136
|
return {
|
|
117
137
|
name: 'run_code',
|
|
118
138
|
description: '执行一段 erasable TypeScript 程序(PTC)——程序体内经 tools.<name>(args) 调用已注册工具',
|
package/src/ptc/worker-entry.ts
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* - 程序体 = async 函数体(顶层 await/return 支持,dsh 一手口径);
|
|
7
7
|
* - tools proxy:await tools.<name>(args) → postMessage {type:'tool'} → 主线程执行 → {type:'tool-result'} 回传;
|
|
8
8
|
* - 预算:maxWallMs 计时超限 → done(error='budget-exceeded (maxWallMs)');maxOutputBytes 序列化后检查;
|
|
9
|
-
* - done 消息单发即退——程序体 = 一次原子 toolCall(收口点唯一在 executor
|
|
9
|
+
* - done 消息单发即退——程序体 = 一次原子 toolCall(收口点唯一在 executor 返回);
|
|
10
|
+
* - 1.8-D31:done 加法 span 字段(程序窗口 startMs/endMs——worker 侧 Date.now 计时,
|
|
11
|
+
* worker_threads 同进程同时钟域;主线程 relay 收口 car.ptc span——单 exporter 纪律,
|
|
12
|
+
* 本隔离体零独立出站通道;程序未启动〔wallTimer 抢先〕不产 span 字段)。
|
|
10
13
|
*/
|
|
11
14
|
import { parentPort, workerData } from 'node:worker_threads'
|
|
12
15
|
|
|
@@ -32,8 +35,10 @@ const tools: Record<string, (args: unknown) => Promise<unknown>> = new Proxy({},
|
|
|
32
35
|
})
|
|
33
36
|
|
|
34
37
|
const budget = (workerData as { budget: { maxWallMs: number; maxOutputBytes: number } }).budget
|
|
38
|
+
// 1.8-D31:程序窗口计时(null = 程序未启动——wallTimer 抢先不产 span 字段)
|
|
39
|
+
let spanStart: number | null = null
|
|
35
40
|
const finish = (msg: { type: 'done'; result?: unknown; error?: string }) => {
|
|
36
|
-
port.postMessage(msg)
|
|
41
|
+
port.postMessage({ ...msg, ...(spanStart !== null ? { span: { startMs: spanStart, endMs: Date.now() } } : {}) })
|
|
37
42
|
setTimeout(() => process.exit(0), 50) // 让消息冲刷
|
|
38
43
|
}
|
|
39
44
|
|
|
@@ -52,6 +57,7 @@ void (async () => {
|
|
|
52
57
|
const body = js.replace(/^export default /, 'return ')
|
|
53
58
|
// 程序体 = async 函数体(顶层 await/return);tools 为受控 proxy(仅显式调用,无宿主 import 面)
|
|
54
59
|
const fn = new Function('tools', '"use strict";\n' + body + '\n') as (t: typeof tools) => () => Promise<unknown>
|
|
60
|
+
spanStart = Date.now()
|
|
55
61
|
const result = await fn(tools)()
|
|
56
62
|
clearTimeout(wallTimer)
|
|
57
63
|
const out = JSON.stringify(result ?? null)
|
package/src/runtime-core/llm.ts
CHANGED
|
@@ -15,11 +15,18 @@
|
|
|
15
15
|
* - declaredSideEffect 不出站(权限面只进权限门,不进模型请求体);
|
|
16
16
|
* - 1.5-S1(D-20):内置 anthropic 适配器(Messages API 流式;max_tokens 必填缺省 4096;
|
|
17
17
|
* system 顶层化;凭据 provider='anthropic')。
|
|
18
|
+
* - 1.8-BUG-1:出站缺省通道 node:http(s)(nodeHttpSseFetch——1.4-BUG-2 口径延伸到 LLM 面):
|
|
19
|
+
* 全局 fetch/undici socket 在 win32 进程退出时触发 libuv 断言崩溃(UV_HANDLE_CLOSING,
|
|
20
|
+
* 0xC0000409)——car run 真路径 ≥3 次模型请求 + PTC worker/MCP 子进程并存即确定性复现
|
|
21
|
+
* (1.4 彼时仅修遥测出站,LLM 出站同为 undici 默认——潜伏至 1.8 E2E 三请求形态暴露);
|
|
22
|
+
* 流式契约 body = AsyncIterable<Uint8Array> 不变;测试注入缝 fetchImpl 保留。
|
|
18
23
|
*/
|
|
19
24
|
import { CarM8Error, providerUnreachable } from './errors.ts'
|
|
20
25
|
import { CredentialService } from './credentials.ts'
|
|
21
26
|
import type { Disposable, LlmAdapter, LlmChunk, LlmRequest, ToolCallDelta } from './types.ts'
|
|
22
27
|
import type { PluginContext } from '../kernel/context.ts'
|
|
28
|
+
import { request as httpRequest } from 'node:http'
|
|
29
|
+
import { request as httpsRequest } from 'node:https'
|
|
23
30
|
|
|
24
31
|
// ==================== 适配器注册表(registerLlmAdapter) ====================
|
|
25
32
|
|
|
@@ -140,6 +147,44 @@ interface SsePostSpec {
|
|
|
140
147
|
sleep: (ms: number) => Promise<void>
|
|
141
148
|
}
|
|
142
149
|
|
|
150
|
+
/** 1.8-BUG-1:node:http(s) 出站响应(streamSsePost 消费契约子集——ok/status/text()/body 流) */
|
|
151
|
+
interface SseFetchResponse { ok: boolean; status: number; text(): Promise<string>; body: AsyncIterable<Uint8Array> }
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 1.8-BUG-1:LLM 出站缺省通道——node:http(s) 流式 POST(1.4-BUG-2 同源口径:全局 fetch/undici
|
|
155
|
+
* socket + win32 进程退出触发 libuv 断言崩溃 0xC0000409;详见面头注记)。流式契约:IncomingMessage
|
|
156
|
+
* 本体即 AsyncIterable<Uint8Array>(Buffer 子类);取消经 AbortSignal → req.destroy(流中途 destroy
|
|
157
|
+
* = 迭代器抛错,streamSsePost D-19 路径收口);传输层超时不设——§3.5.4 firstByte/overall 由调用方
|
|
158
|
+
* AbortController 单点承载。
|
|
159
|
+
*/
|
|
160
|
+
const nodeHttpSseFetch = (url: string, init: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal } = {}): Promise<SseFetchResponse> => new Promise((resolve, reject) => {
|
|
161
|
+
try {
|
|
162
|
+
const u = new URL(url)
|
|
163
|
+
const mod = u.protocol === 'https:' ? httpsRequest : httpRequest
|
|
164
|
+
const req = mod(u, { method: init.method ?? 'GET', headers: init.headers }, res => {
|
|
165
|
+
const status = res.statusCode ?? 0
|
|
166
|
+
resolve({
|
|
167
|
+
ok: status >= 200 && status < 300,
|
|
168
|
+
status,
|
|
169
|
+
text: () => new Promise<string>((res2, rej2) => {
|
|
170
|
+
let s = ''
|
|
171
|
+
res.on('data', c => { s += c.toString('utf-8') })
|
|
172
|
+
res.on('end', () => res2(s))
|
|
173
|
+
res.on('error', rej2)
|
|
174
|
+
}),
|
|
175
|
+
body: res,
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
const onAbort = () => { try { req.destroy(new Error('aborted')) } catch { /* 已完成 */ } }
|
|
179
|
+
if (init.signal) {
|
|
180
|
+
if (init.signal.aborted) onAbort()
|
|
181
|
+
else init.signal.addEventListener('abort', onAbort, { once: true })
|
|
182
|
+
}
|
|
183
|
+
req.on('error', reject)
|
|
184
|
+
req.end(init.body ?? null)
|
|
185
|
+
} catch (e) { reject(e) }
|
|
186
|
+
})
|
|
187
|
+
|
|
143
188
|
/**
|
|
144
189
|
* §3.5.4 流式 POST 引擎(openai-compat / anthropic 单点共用,1.5-S1 抽取——两适配器重试/
|
|
145
190
|
* 超时/取消语义不漂移):仅网络错误/429/5xx、≤2 次、指数退避 1s/2s;首块前失败才可重试
|
|
@@ -300,7 +345,8 @@ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter
|
|
|
300
345
|
// 1.4-S4(D-12a,1.2 规划期裁决):TLS 门 1.5-S1 抽取共用(口径与报错文案不变)
|
|
301
346
|
assertTlsOrLoopback(baseUrl)
|
|
302
347
|
const url = `${baseUrl}/chat/completions`
|
|
303
|
-
|
|
348
|
+
// 1.8-BUG-1:缺省 node:http(s)(undici 退出崩溃——面头注记);注入缝保留
|
|
349
|
+
const fetchImpl = opts.fetchImpl ?? nodeHttpSseFetch as unknown as typeof fetch
|
|
304
350
|
const sleep = opts.sleep ?? (ms => new Promise<void>(r => setTimeout(r, ms)))
|
|
305
351
|
const timeoutMs = opts.timeoutMs ?? 120_000
|
|
306
352
|
const firstByteMs = opts.firstByteMs ?? 30_000
|
|
@@ -503,7 +549,8 @@ export function createAnthropicAdapter(opts: AnthropicAdapterOptions): LlmAdapte
|
|
|
503
549
|
const baseUrl = opts.baseUrl.replace(/\/+$/, '')
|
|
504
550
|
assertTlsOrLoopback(baseUrl)
|
|
505
551
|
const url = `${baseUrl}/v1/messages`
|
|
506
|
-
|
|
552
|
+
// 1.8-BUG-1:缺省 node:http(s)(undici 退出崩溃——面头注记);注入缝保留
|
|
553
|
+
const fetchImpl = opts.fetchImpl ?? nodeHttpSseFetch as unknown as typeof fetch
|
|
507
554
|
const sleep = opts.sleep ?? (ms => new Promise<void>(r => setTimeout(r, ms)))
|
|
508
555
|
const timeoutMs = opts.timeoutMs ?? 120_000
|
|
509
556
|
const firstByteMs = opts.firstByteMs ?? 30_000
|
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
* - metrics 累计基数保持:flush 不再清空 counters——每次导出全量累计快照(真 CUMULATIVE)。
|
|
33
33
|
* 1.2-S3 起 clear() 与 AGGREGATION_TEMPORALITY_CUMULATIVE 标注矛盾:intervalMs>0 自动导出时
|
|
34
34
|
* 下游把增量解读为总量回落;内存有界由 label 基数保证(零内容枚举红线——AllowedLabels 3 计数器)。
|
|
35
|
+
*
|
|
36
|
+
* 1.8(D-31 PTC worker relay span 时刻覆盖):startSpan 加法 time.startMs / end 加法 atMs——
|
|
37
|
+
* 跨隔离体计时回传(worker_threads 同进程同时钟域)后主线程单点收口(trace.ts ptcRelay);
|
|
38
|
+
* 采样/buffer 上限/出站形态路径零改动(relay span 走既有 end() 全路径)。缺省时刻行为不变。
|
|
35
39
|
*/
|
|
36
40
|
import { randomBytes } from 'node:crypto'
|
|
37
41
|
import { request as httpRequest } from 'node:http'
|
|
@@ -67,7 +71,8 @@ export interface TelemetrySpan {
|
|
|
67
71
|
setAttribute(key: string, value: string | number | boolean): this
|
|
68
72
|
addEvent(name: string, attrs?: Record<string, string | number | boolean>): this
|
|
69
73
|
recordException(err: unknown): this
|
|
70
|
-
|
|
74
|
+
/** 1.8-D31:atMs = 结束时刻覆盖(跨隔离体计时 relay;缺省 now() 行为不变) */
|
|
75
|
+
end(atMs?: number): void
|
|
71
76
|
readonly traceId: string
|
|
72
77
|
/** 1.4-S6:子 span 挂链需要父 spanId(TurnTracer turn→step 层级) */
|
|
73
78
|
readonly spanId: string
|
|
@@ -78,6 +83,8 @@ export interface TelemetryTracer {
|
|
|
78
83
|
attributes?: Record<string, string | number | boolean>
|
|
79
84
|
/** 1.4-S6:trace 透传(traceId 复用 + parentSpanId 挂链);缺省 = 新独立 trace */
|
|
80
85
|
trace?: { traceId: string; parentSpanId?: string }
|
|
86
|
+
/** 1.8-D31:起始时刻覆盖(跨隔离体计时 relay;缺省 now() 行为不变) */
|
|
87
|
+
time?: { startMs: number }
|
|
81
88
|
}): TelemetrySpan
|
|
82
89
|
}
|
|
83
90
|
|
|
@@ -341,7 +348,8 @@ export function createTelemetryFacade(
|
|
|
341
348
|
spanId: randomBytes(8).toString('hex'),
|
|
342
349
|
parentSpanId: startOpts?.trace?.parentSpanId ?? null,
|
|
343
350
|
name,
|
|
344
|
-
|
|
351
|
+
// 1.8-D31:起始时刻覆盖(跨隔离体计时 relay;缺省 now() 行为不变)
|
|
352
|
+
startMs: startOpts?.time?.startMs ?? now(),
|
|
345
353
|
endMs: 0,
|
|
346
354
|
attributes: { ...startOpts?.attributes },
|
|
347
355
|
events: [],
|
|
@@ -367,8 +375,8 @@ export function createTelemetryFacade(
|
|
|
367
375
|
record.status = 'STATUS_CODE_ERROR'
|
|
368
376
|
return this
|
|
369
377
|
},
|
|
370
|
-
end() {
|
|
371
|
-
record.endMs = now()
|
|
378
|
+
end(atMs) {
|
|
379
|
+
record.endMs = atMs ?? now()
|
|
372
380
|
stats.spansEnded++
|
|
373
381
|
// head 采样于 end() 决策(1.2-S3):缺省 always_on 全量(1.1 行为不变);采样只减少出站
|
|
374
382
|
const keep = sampling === 'always_on' ? true
|
|
@@ -12,6 +12,23 @@
|
|
|
12
12
|
* 无 model() 循环,step span 面外推登记为不适用)。facade 内部自增派生 turnId(T{n}),装配层
|
|
13
13
|
* 调用前不可知——TurnSpanHandle 加法 `setTurnId` 回填面(car run 直传 turnId 路径不受影响);
|
|
14
14
|
* startTurn('') 空值不设 car.turn_id 属性(回填前出站形态无占位内容)。
|
|
15
|
+
*
|
|
16
|
+
* 1.8(D-29/D-30/D-31 · W3C traceparent 跨进程传播):自研 traceId(16B hex)与 spanId(8B hex)
|
|
17
|
+
* 格式天然满足 W3C Trace Context(trace-id 32hex / span-id 16hex),1.8 补齐编解码与三边界载体:
|
|
18
|
+
* - formatTraceparent/parseTraceparent(D-29):纯字符串编解码,零依赖;parse 严格校验
|
|
19
|
+
* (version 00 / 32hex / 16hex / 全零拒绝),畸形返回 null——**协议面 fail-open**(挂链放弃、
|
|
20
|
+
* 新 trace,遥测面禁 fail-hard——BD-05 同源);flags 出站恒 '01'(本端记录中——采样为端点
|
|
21
|
+
* 本地决策,不跨端传播语义);
|
|
22
|
+
* - mcpToolSpan(D-29):car run 外呼 MCP 工具的 client span(parent = turn span——step span 已于
|
|
23
|
+
* model() 返回时收口,工具执行在 step 外);attributes car.mcp_server/car.tool 为**登记标识符面**
|
|
24
|
+
* (零内容口径扩展:注册表枚举标识,非会话内容);traceparent 供 gateway.callTool 注入
|
|
25
|
+
* params._meta(otel 关 = '' 不注入,线上 JSON-RPC 字节面不变);
|
|
26
|
+
* - startTurn 加法 opts.trace(D-30):mcp-serve 服务端挂链远端宿主——traceId 复用远端 +
|
|
27
|
+
* parentSpanId = 远端 spanId,car.turn span 加入宿主 trace(fail-open:畸形 traceparent 由
|
|
28
|
+
* 调用方 parse 为 null 后不传,新 trace);
|
|
29
|
+
* - ptcRelay(D-31):PTC worker 桥挂链——workerTrace 载荷(traceId/parentSpanId)经 workerData
|
|
30
|
+
* 进隔离体,worker 计时经 done 消息回传后 complete() 主线程单点收口 car.ptc span(facade
|
|
31
|
+
* time.startMs/end(atMs) 时刻覆盖;单 exporter 纪律——worker 零独立出站通道)。
|
|
15
32
|
*/
|
|
16
33
|
import { randomBytes } from 'node:crypto'
|
|
17
34
|
import type { TelemetryFacade } from './telemetry.ts'
|
|
@@ -21,6 +38,20 @@ export interface StepSpanHandle {
|
|
|
21
38
|
recordException(e: unknown): void
|
|
22
39
|
}
|
|
23
40
|
|
|
41
|
+
/** 1.8-D29:MCP 外呼 client span 句柄——traceparent = '' 表示无遥测(不注入 _meta) */
|
|
42
|
+
export interface McpClientSpanHandle {
|
|
43
|
+
readonly traceparent: string
|
|
44
|
+
end(outcome: string): void
|
|
45
|
+
recordException(e: unknown): void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 1.8-D31:PTC worker 桥挂链句柄——workerTrace=null 表示无遥测(worker 零感知零改动) */
|
|
49
|
+
export interface PtcRelayHandle {
|
|
50
|
+
readonly workerTrace: { traceId: string; parentSpanId: string } | null
|
|
51
|
+
/** worker 计时回传收口(主线程单点出站;error 路径 recordException D-26 形态) */
|
|
52
|
+
complete(startMs: number, endMs: number, outcome: string, error?: unknown): void
|
|
53
|
+
}
|
|
54
|
+
|
|
24
55
|
export interface TurnSpanHandle {
|
|
25
56
|
traceId: string
|
|
26
57
|
spanId: string
|
|
@@ -30,6 +61,30 @@ export interface TurnSpanHandle {
|
|
|
30
61
|
recordException(e: unknown): void
|
|
31
62
|
end(outcome: string): void
|
|
32
63
|
stepSpan(step: number): StepSpanHandle
|
|
64
|
+
/** 1.8-D29:MCP 外呼 client span(parent = turn span;noop 门面 traceparent='' 零注入) */
|
|
65
|
+
mcpToolSpan(serverId: string, tool: string): McpClientSpanHandle
|
|
66
|
+
/** 1.8-D31:PTC worker 桥挂链面(noop 门面 workerTrace=null 零载荷) */
|
|
67
|
+
ptcRelay(): PtcRelayHandle
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 1.8-D29:W3C traceparent 出站形态(flags '01' = 本端记录中;采样为端点本地决策不跨端传播) */
|
|
71
|
+
export function formatTraceparent(traceId: string, spanId: string): string {
|
|
72
|
+
return `00-${traceId}-${spanId}-01`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const W3C_TP_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 1.8-D29:W3C traceparent 解析——严格校验(version 00 / trace-id 32hex 非 / span-id 16hex 非 / 全零拒绝)。
|
|
79
|
+
* 畸形返回 null = 挂链放弃(调用方走新 trace——协议面 fail-open,禁 fail-hard)。
|
|
80
|
+
*/
|
|
81
|
+
export function parseTraceparent(tp: string): { traceId: string; parentSpanId: string } | null {
|
|
82
|
+
const m = W3C_TP_RE.exec(tp.trim())
|
|
83
|
+
if (!m) return null
|
|
84
|
+
const traceId = m[1]!
|
|
85
|
+
const parentSpanId = m[2]!
|
|
86
|
+
if (/^0+$/.test(traceId) || /^0+$/.test(parentSpanId)) return null
|
|
87
|
+
return { traceId, parentSpanId }
|
|
33
88
|
}
|
|
34
89
|
|
|
35
90
|
export class TurnTracer {
|
|
@@ -40,13 +95,22 @@ export class TurnTracer {
|
|
|
40
95
|
this.#facade = facade?.enabled ? facade : null
|
|
41
96
|
}
|
|
42
97
|
|
|
43
|
-
|
|
98
|
+
/** 1.8-D30:opts.trace = 远端挂链(mcp-serve session_turn `_meta.traceparent` 提取后传入;缺省新 trace 不变) */
|
|
99
|
+
startTurn(turnId: string, opts?: { trace?: { traceId: string; parentSpanId: string } }): TurnSpanHandle {
|
|
44
100
|
if (!this.#facade) {
|
|
45
|
-
return {
|
|
101
|
+
return {
|
|
102
|
+
traceId: '', spanId: '', setTurnId() {}, recordException() {}, end() {},
|
|
103
|
+
stepSpan: () => ({ end() {}, recordException() {} }),
|
|
104
|
+
mcpToolSpan: () => ({ traceparent: '', end() {}, recordException() {} }),
|
|
105
|
+
ptcRelay: () => ({ workerTrace: null, complete() {} }),
|
|
106
|
+
}
|
|
46
107
|
}
|
|
47
108
|
const tracer = this.#facade.getTracer()
|
|
48
|
-
const traceId = randomBytes(16).toString('hex')
|
|
49
|
-
const turn = tracer.startSpan('car.turn', {
|
|
109
|
+
const traceId = opts?.trace?.traceId ?? randomBytes(16).toString('hex')
|
|
110
|
+
const turn = tracer.startSpan('car.turn', {
|
|
111
|
+
attributes: turnId ? { 'car.turn_id': turnId } : {},
|
|
112
|
+
trace: { traceId, ...(opts?.trace?.parentSpanId ? { parentSpanId: opts.trace.parentSpanId } : {}) },
|
|
113
|
+
})
|
|
50
114
|
return {
|
|
51
115
|
traceId,
|
|
52
116
|
spanId: turn.spanId,
|
|
@@ -69,6 +133,38 @@ export class TurnTracer {
|
|
|
69
133
|
recordException(e) { s.recordException(e) },
|
|
70
134
|
}
|
|
71
135
|
},
|
|
136
|
+
// 1.8-D29:client span 挂 turn(工具执行在 step span 收口后——turn 为最近存活父)
|
|
137
|
+
mcpToolSpan(serverId, tool) {
|
|
138
|
+
const s = tracer.startSpan('car.mcp.tool', {
|
|
139
|
+
attributes: { 'car.mcp_server': serverId, 'car.tool': tool },
|
|
140
|
+
trace: { traceId, parentSpanId: turn.spanId },
|
|
141
|
+
})
|
|
142
|
+
return {
|
|
143
|
+
traceparent: formatTraceparent(traceId, s.spanId),
|
|
144
|
+
end(outcome) {
|
|
145
|
+
s.setAttribute('car.outcome', outcome)
|
|
146
|
+
s.end()
|
|
147
|
+
},
|
|
148
|
+
recordException(e) { s.recordException(e) },
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
// 1.8-D31:worker 侧计时 relay——span 生成收口在主线程(单 exporter 纪律)
|
|
152
|
+
ptcRelay(): PtcRelayHandle {
|
|
153
|
+
return {
|
|
154
|
+
workerTrace: { traceId, parentSpanId: turn.spanId },
|
|
155
|
+
complete(startMs, endMs, outcome, error) {
|
|
156
|
+
// 负时长防线(同时钟域不应出现——防御下游转换器语义错乱)
|
|
157
|
+
const s = tracer.startSpan('car.ptc', {
|
|
158
|
+
attributes: { 'car.turn_id': turnId },
|
|
159
|
+
trace: { traceId, parentSpanId: turn.spanId },
|
|
160
|
+
time: { startMs },
|
|
161
|
+
})
|
|
162
|
+
if (error !== undefined) s.recordException(error instanceof Error ? error : new Error(String(error)))
|
|
163
|
+
s.setAttribute('car.outcome', outcome)
|
|
164
|
+
s.end(Math.max(endMs, startMs))
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
},
|
|
72
168
|
}
|
|
73
169
|
}
|
|
74
170
|
}
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.8 · W3C traceparent 跨进程传播(D-29/D-30/D-31 · S42)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(1.8-迭代规划 TRACE-1/2/3):
|
|
5
|
+
* - 单元:W3C 编解码(合法/畸形/全零 fail-open);mcpToolSpan client span(挂 turn + traceparent
|
|
6
|
+
* 形态 + noop 零注入);startTurn opts.trace 远端挂链;ptcRelay relay span(worker 时刻覆盖 +
|
|
7
|
+
* error 路径);gateway.callTool `_meta` 注入与缺省不注入(字节面兼容);
|
|
8
|
+
* - E2E-A:car run --mcp --ptc 全链——假 MCP server 捕获 `_meta.traceparent` 回显进工具结果,
|
|
9
|
+
* 与 OTLP 出站 car.mcp.tool span 三点对拍闭合(traceId + spanId)+ car.ptc relay span 挂链
|
|
10
|
+
* turn + worker 计时窗口;零内容断言(结果文本/提示词不出站);
|
|
11
|
+
* - E2E-B:mcp-serve 收 `_meta.traceparent` → car.turn span 加入宿主 trace(traceId 复用 +
|
|
12
|
+
* parentSpanId = 远端 spanId);畸形 traceparent fail-open 新 trace。
|
|
13
|
+
*/
|
|
14
|
+
import { test } from 'node:test'
|
|
15
|
+
import assert from 'node:assert/strict'
|
|
16
|
+
import { spawn } from 'node:child_process'
|
|
17
|
+
import { createServer, type Server } from 'node:http'
|
|
18
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { tmpdir } from 'node:os'
|
|
20
|
+
import { join, dirname } from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import { createTelemetryFacade } from '../src/runtime-core/telemetry.ts'
|
|
23
|
+
import { TurnTracer, formatTraceparent, parseTraceparent } from '../src/runtime-core/trace.ts'
|
|
24
|
+
import { McpGateway, type ClientTransport, type JsonRpcRequest, type JsonRpcResponse } from '../src/mcp/gateway.ts'
|
|
25
|
+
|
|
26
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
27
|
+
const CLI = join(ROOT, 'src', 'cli.ts')
|
|
28
|
+
|
|
29
|
+
const okRes = () => ({ ok: true, status: 200 }) as unknown as Response
|
|
30
|
+
|
|
31
|
+
// 密钥扫描口径:夹具凭据经变量注入(赋值行无引号字面量)
|
|
32
|
+
const TEST_KEY = 'test-key-123'
|
|
33
|
+
|
|
34
|
+
function withTempDir(name: string, fn: (dir: string) => Promise<void>): Promise<void> {
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), `car-s42-${name}-`))
|
|
36
|
+
return fn(dir).finally(() => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sse = (chunks: object[]) => chunks.map(c => `data: ${JSON.stringify(c)}\n\n`).join('') + 'data: [DONE]\n\n'
|
|
40
|
+
|
|
41
|
+
// ==================== 单元:W3C 编解码(D-29) ====================
|
|
42
|
+
|
|
43
|
+
test('S42: formatTraceparent/parseTraceparent——W3C 形态 + 畸形 fail-open(null 不抛)', () => {
|
|
44
|
+
const tid = 'a'.repeat(32)
|
|
45
|
+
const sid = 'b'.repeat(16)
|
|
46
|
+
assert.equal(formatTraceparent(tid, sid), `00-${tid}-${sid}-01`, 'flags 01(本端记录中)')
|
|
47
|
+
assert.deepEqual(parseTraceparent(`00-${tid}-${sid}-01`), { traceId: tid, parentSpanId: sid })
|
|
48
|
+
assert.deepEqual(parseTraceparent(`00-${tid}-${sid}-ff`), { traceId: tid, parentSpanId: sid }, 'flags 任意值接受(挂链不消费 flags)')
|
|
49
|
+
// 畸形 fail-open:全 null 不抛(遥测面禁 fail-hard——BD-05 同源)
|
|
50
|
+
assert.equal(parseTraceparent('garbage'), null, '非格式串')
|
|
51
|
+
assert.equal(parseTraceparent(`ff-${tid}-${sid}-01`), null, 'version 非 00')
|
|
52
|
+
assert.equal(parseTraceparent(`00-${'a'.repeat(31)}-${sid}-01`), null, 'trace-id 错长')
|
|
53
|
+
assert.equal(parseTraceparent(`00-${tid}-${'b'.repeat(15)}-01`), null, 'span-id 错长')
|
|
54
|
+
assert.equal(parseTraceparent(`00-${'0'.repeat(32)}-${sid}-01`), null, 'trace-id 全零(W3C 无效)')
|
|
55
|
+
assert.equal(parseTraceparent(`00-${tid}-${'0'.repeat(16)}-01`), null, 'span-id 全零(W3C 无效)')
|
|
56
|
+
assert.equal(parseTraceparent(`00-${'g'.repeat(32)}-${sid}-01`), null, '非 hex')
|
|
57
|
+
// 自研 id 形态天然合规:16B/8B hex 往返
|
|
58
|
+
const rt = parseTraceparent(formatTraceparent(tid, sid))
|
|
59
|
+
assert.deepEqual(rt, { traceId: tid, parentSpanId: sid }, 'format→parse 往返一致')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// ==================== 单元:mcpToolSpan client span(D-29) ====================
|
|
63
|
+
|
|
64
|
+
test('S42: mcpToolSpan——car.mcp.tool 挂 turn + traceparent 形态;noop 门面 traceparent 零注入', async () => {
|
|
65
|
+
await withTempDir('client-span', async () => {
|
|
66
|
+
const bodies: any[] = []
|
|
67
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
68
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
69
|
+
})
|
|
70
|
+
const tracer = new TurnTracer(f)
|
|
71
|
+
const turn = tracer.startTurn('T0')
|
|
72
|
+
const span = turn.mcpToolSpan('helper', 'upper')
|
|
73
|
+
// traceparent 形态 = 00-{turn traceId}-{本 span spanId}-01
|
|
74
|
+
const parsed = parseTraceparent(span.traceparent)
|
|
75
|
+
assert.ok(parsed, 'traceparent 可解析')
|
|
76
|
+
assert.equal(parsed!.traceId, turn.traceId, 'client span traceId = turn traceId')
|
|
77
|
+
span.end('ok')
|
|
78
|
+
const span2 = turn.mcpToolSpan('helper', 'upper')
|
|
79
|
+
span2.recordException(new Error('boom'))
|
|
80
|
+
span2.end('error')
|
|
81
|
+
turn.end('completed')
|
|
82
|
+
await f.flush()
|
|
83
|
+
const spans = bodies.flatMap(b => b.scopeSpans[0].spans)
|
|
84
|
+
const mcp = spans.filter((s: any) => s.name === 'car.mcp.tool')
|
|
85
|
+
assert.equal(mcp.length, 2, '两次外呼两个 client span')
|
|
86
|
+
assert.equal(mcp[0].parentSpanId, spans.find((s: any) => s.name === 'car.turn').spanId, 'client span 父 = turn spanId')
|
|
87
|
+
assert.equal(mcp[0].traceId, turn.traceId, '共享 trace')
|
|
88
|
+
const attrs = Object.fromEntries(mcp[0].attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
89
|
+
assert.equal(attrs['car.mcp_server'], 'helper', 'serverId 标识符面(零内容口径扩展登记)')
|
|
90
|
+
assert.equal(attrs['car.tool'], 'upper')
|
|
91
|
+
assert.equal(attrs['car.outcome'], 'ok')
|
|
92
|
+
// 本 span spanId 与 traceparent 三点一致(E2E-A 端到端对拍的单元基线)
|
|
93
|
+
const outSpanId = mcp[0].spanId
|
|
94
|
+
assert.equal(parsed!.parentSpanId, outSpanId, 'traceparent spanId = 出站 spanId')
|
|
95
|
+
const errAttrs = Object.fromEntries(mcp[1].attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
96
|
+
assert.equal(errAttrs['car.outcome'], 'error')
|
|
97
|
+
assert.equal(mcp[1].status.code, 'STATUS_CODE_ERROR', 'error 路径 D-26 形态')
|
|
98
|
+
// noop 门面:traceparent=''(调用方不注入 _meta——字节面兼容线)
|
|
99
|
+
const noopTracer = new TurnTracer(createTelemetryFacade(null))
|
|
100
|
+
const noopSpan = noopTracer.startTurn('T0').mcpToolSpan('helper', 'upper')
|
|
101
|
+
assert.equal(noopSpan.traceparent, '', 'noop 零注入面')
|
|
102
|
+
noopSpan.end('ok')
|
|
103
|
+
noopTracer.startTurn('T0').ptcRelay().complete(0, 0, 'ok') // noop relay 零抛
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// ==================== 单元:startTurn 远端挂链(D-30) + ptcRelay(D-31) ====================
|
|
108
|
+
|
|
109
|
+
test('S42: startTurn opts.trace——turn span 加入远端 trace(traceId 复用 + parentSpanId)', async () => {
|
|
110
|
+
await withTempDir('server-link', async () => {
|
|
111
|
+
const bodies: any[] = []
|
|
112
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
113
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
114
|
+
})
|
|
115
|
+
const tracer = new TurnTracer(f)
|
|
116
|
+
const remoteTraceId = 'c'.repeat(32)
|
|
117
|
+
const remoteSpanId = 'd'.repeat(16)
|
|
118
|
+
const turn = tracer.startTurn('', { trace: { traceId: remoteTraceId, parentSpanId: remoteSpanId } })
|
|
119
|
+
turn.setTurnId('T1')
|
|
120
|
+
turn.end('completed')
|
|
121
|
+
await f.flush()
|
|
122
|
+
const spans = bodies.flatMap(b => b.scopeSpans[0].spans)
|
|
123
|
+
const t = spans.find((s: any) => s.name === 'car.turn')
|
|
124
|
+
assert.equal(t.traceId, remoteTraceId, 'traceId 复用远端(加入宿主 trace)')
|
|
125
|
+
assert.equal(t.parentSpanId, remoteSpanId, 'parentSpanId = 远端 spanId')
|
|
126
|
+
// step 子 span 同 trace 挂 turn(挂链后层级不破坏)
|
|
127
|
+
const turn2 = tracer.startTurn('', { trace: { traceId: remoteTraceId, parentSpanId: remoteSpanId } })
|
|
128
|
+
turn2.stepSpan(1).end('stop')
|
|
129
|
+
turn2.end('completed')
|
|
130
|
+
await f.flush()
|
|
131
|
+
const spans2 = bodies.flatMap(b => b.scopeSpans[0].spans)
|
|
132
|
+
const step = spans2.find((s: any) => s.name === 'car.step')
|
|
133
|
+
assert.equal(step.traceId, remoteTraceId)
|
|
134
|
+
assert.equal(step.parentSpanId, spans2.filter((s: any) => s.name === 'car.turn').at(-1)!.spanId)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('S42: ptcRelay——car.ptc span 主线程收口(worker 时刻覆盖 + 挂链 turn + error 形态)', async () => {
|
|
139
|
+
await withTempDir('ptc-relay', async () => {
|
|
140
|
+
const bodies: any[] = []
|
|
141
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
142
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
143
|
+
})
|
|
144
|
+
const tracer = new TurnTracer(f)
|
|
145
|
+
const turn = tracer.startTurn('T0')
|
|
146
|
+
const relay = turn.ptcRelay()
|
|
147
|
+
assert.deepEqual(relay.workerTrace, { traceId: turn.traceId, parentSpanId: turn.spanId }, 'workerTrace 载荷 = turn 挂链')
|
|
148
|
+
// ok 路径:worker 计时时刻覆盖(1000→1060 不经 now()——精确出站)
|
|
149
|
+
relay.complete(1000, 1060, 'ok')
|
|
150
|
+
// error 路径:recordException D-26 形态
|
|
151
|
+
turn.ptcRelay().complete(2000, 2250, 'error', new Error('boom'))
|
|
152
|
+
turn.end('completed')
|
|
153
|
+
await f.flush()
|
|
154
|
+
const spans = bodies.flatMap(b => b.scopeSpans[0].spans)
|
|
155
|
+
const ptc = spans.filter((s: any) => s.name === 'car.ptc')
|
|
156
|
+
assert.equal(ptc.length, 2)
|
|
157
|
+
assert.equal(ptc[0].startTimeUnixNano, String(1000 * 1e6), 'startMs 时刻覆盖(非 now())')
|
|
158
|
+
assert.equal(ptc[0].endTimeUnixNano, String(1060 * 1e6), 'endMs 时刻覆盖')
|
|
159
|
+
assert.equal(ptc[0].parentSpanId, turn.spanId, 'car.ptc 挂 turn')
|
|
160
|
+
assert.equal(ptc[0].traceId, turn.traceId, '同 trace')
|
|
161
|
+
assert.equal(ptc[0].status.code, 'STATUS_CODE_UNSET')
|
|
162
|
+
assert.equal(ptc[1].status.code, 'STATUS_CODE_ERROR', 'error 路径 status')
|
|
163
|
+
const ev = ptc[1].events?.find((e: any) => e.name === 'exception')
|
|
164
|
+
assert.ok(ev, 'error 路径 exception 事件(D-26)')
|
|
165
|
+
// 负时长防线:endMs < startMs 收口为 startMs(防御下游转换器)
|
|
166
|
+
turn.ptcRelay().complete(3000, 2999, 'ok')
|
|
167
|
+
turn.end('completed')
|
|
168
|
+
await f.flush()
|
|
169
|
+
const ptc3 = bodies.flatMap(b => b.scopeSpans[0].spans).filter((s: any) => s.name === 'car.ptc').at(-1)!
|
|
170
|
+
assert.equal(ptc3.endTimeUnixNano, ptc3.startTimeUnixNano, '负时长钳制')
|
|
171
|
+
// noop 门面:workerTrace null + complete 零出站
|
|
172
|
+
const noopRelay = new TurnTracer(createTelemetryFacade(null)).startTurn('T0').ptcRelay()
|
|
173
|
+
assert.equal(noopRelay.workerTrace, null)
|
|
174
|
+
noopRelay.complete(0, 0, 'ok')
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// ==================== 单元:gateway.callTool `_meta` 注入(D-29) ====================
|
|
179
|
+
|
|
180
|
+
test('S42: callTool traceparent 注入 params._meta;缺省无 _meta 键(字节面兼容)', async () => {
|
|
181
|
+
await withTempDir('meta', async () => {
|
|
182
|
+
const sent: JsonRpcRequest[] = []
|
|
183
|
+
const transport: ClientTransport = {
|
|
184
|
+
send: async (req) => {
|
|
185
|
+
sent.push(req)
|
|
186
|
+
// tools/list 需回工具面(登记制:无工具则 callTool 不发送直接 unknown tool)
|
|
187
|
+
const result = req.method === 'tools/list'
|
|
188
|
+
? { tools: [{ name: 'upper', sideEffect: 'readonly' }] }
|
|
189
|
+
: {}
|
|
190
|
+
return { jsonrpc: '2.0', id: req.id, result } as JsonRpcResponse
|
|
191
|
+
},
|
|
192
|
+
alive: () => true,
|
|
193
|
+
close: () => {},
|
|
194
|
+
}
|
|
195
|
+
const gw = new McpGateway()
|
|
196
|
+
await gw.register({ serverId: 'helper', transport })
|
|
197
|
+
sent.length = 0
|
|
198
|
+
await gw.callTool('helper', 'upper', { text: 'x' }, { traceparent: formatTraceparent('a'.repeat(32), 'b'.repeat(16)) })
|
|
199
|
+
assert.equal((sent[0].params as any)._meta?.traceparent, formatTraceparent('a'.repeat(32), 'b'.repeat(16)), '_meta.traceparent 注入')
|
|
200
|
+
assert.equal(sent[0].params && 'name' in (sent[0].params as object), true, 'params.name 保持')
|
|
201
|
+
await gw.callTool('helper', 'upper', { text: 'x' })
|
|
202
|
+
const p2 = sent[1].params as Record<string, unknown>
|
|
203
|
+
assert.equal('_meta' in p2, false, '缺省不注入(线上 JSON-RPC 字节面不变)')
|
|
204
|
+
await gw.callTool('helper', 'upper', { text: 'x' }, { traceparent: '' })
|
|
205
|
+
assert.equal('_meta' in (sent[2].params as object), false, '空串不注入(noop 门面路径)')
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// ==================== E2E-A:car run --mcp --ptc 全链(D-29/D-31 生产调用点) ====================
|
|
210
|
+
|
|
211
|
+
/** 受控 stdio MCP server(node 脚本文本):tools/call 捕获 _meta.traceparent 回显进工具结果 */
|
|
212
|
+
const FAKE_MCP_SERVER = `
|
|
213
|
+
const lines = [];
|
|
214
|
+
let buf = '';
|
|
215
|
+
process.stdin.on('data', d => {
|
|
216
|
+
buf += d.toString();
|
|
217
|
+
let nl;
|
|
218
|
+
while ((nl = buf.indexOf('\\n')) >= 0) {
|
|
219
|
+
const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);
|
|
220
|
+
if (!line) continue;
|
|
221
|
+
const req = JSON.parse(line);
|
|
222
|
+
if (req.method === 'tools/list') {
|
|
223
|
+
reply(req, { tools: [{ name: 'upper', description: '大写工具', sideEffect: 'readonly', inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] } }] });
|
|
224
|
+
} else if (req.method === 'tools/call') {
|
|
225
|
+
const tp = (req.params._meta || {}).traceparent || 'none';
|
|
226
|
+
reply(req, { content: [{ type: 'text', text: String(req.params.arguments.text).toUpperCase() + '|tp:' + tp }] });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
function reply(req, result) { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\\n'); }
|
|
231
|
+
`
|
|
232
|
+
|
|
233
|
+
const RUN_CODE = `const r = await tools.mcp_helper_upper({ text: 'abc' })\nawait new Promise(res => setTimeout(res, 60))\nreturn r`
|
|
234
|
+
|
|
235
|
+
test('S42 E2E-A: car run 全链——_meta.traceparent 与 OTLP car.mcp.tool 三点闭合 + car.ptc relay span', async () => {
|
|
236
|
+
await withTempDir('full-chain', async dir => {
|
|
237
|
+
// 插件(run 首位参数——s32 同款装配面)
|
|
238
|
+
writeFileSync(join(dir, 'p.ts'), `export const manifest = { name: 'p', version: '1.0.0' }\n` +
|
|
239
|
+
`export default function apply() {}\n`)
|
|
240
|
+
writeFileSync(join(dir, 'car.config.json'), JSON.stringify({
|
|
241
|
+
llm: { model: 'test-model' },
|
|
242
|
+
mcp: { servers: { helper: { command: process.execPath, args: ['-e', FAKE_MCP_SERVER] } } },
|
|
243
|
+
}))
|
|
244
|
+
// 假 LLM:req1 → run_code(worker 桥调 MCP);req2 → 直调 MCP 工具;req3 → done
|
|
245
|
+
const llmBodies: any[] = []
|
|
246
|
+
let n = 0
|
|
247
|
+
const llmServer: Server = createServer((req, res) => {
|
|
248
|
+
let raw = ''
|
|
249
|
+
req.on('data', c => { raw += c })
|
|
250
|
+
req.on('end', () => {
|
|
251
|
+
llmBodies.push(JSON.parse(raw))
|
|
252
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
253
|
+
res.end(n++ === 0
|
|
254
|
+
? sse([
|
|
255
|
+
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'run_code', arguments: JSON.stringify({ code: RUN_CODE, description: '经桥调 MCP 大写' } ) } }] } }] },
|
|
256
|
+
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
|
|
257
|
+
])
|
|
258
|
+
: n === 2
|
|
259
|
+
? sse([
|
|
260
|
+
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c2', function: { name: 'mcp_helper_upper', arguments: JSON.stringify({ text: 'xyz' } ) } }] } }] },
|
|
261
|
+
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
|
|
262
|
+
])
|
|
263
|
+
: sse([{ choices: [{ delta: { role: 'assistant', content: '' } }] }, { choices: [{ delta: { content: 'done' } }] }, { choices: [{ delta: {}, finish_reason: 'stop' }] }]))
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
await new Promise<void>(r => llmServer.listen(0, '127.0.0.1', r))
|
|
267
|
+
const llmPort = (llmServer.address() as { port: number }).port
|
|
268
|
+
// OTLP 捕获端点
|
|
269
|
+
const otlpBodies: any[] = []
|
|
270
|
+
const otlp: Server = createServer((req, res) => {
|
|
271
|
+
let raw = ''
|
|
272
|
+
req.on('data', c => { raw += c })
|
|
273
|
+
req.on('end', () => { if (req.url === '/v1/traces') otlpBodies.push(JSON.parse(raw)); res.writeHead(200).end() })
|
|
274
|
+
})
|
|
275
|
+
await new Promise<void>(r => otlp.listen(0, '127.0.0.1', r))
|
|
276
|
+
const otlpPort = (otlp.address() as { port: number }).port
|
|
277
|
+
|
|
278
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'run', 'p.ts',
|
|
279
|
+
'--prompt', 'uppercase please', '--base-url', `http://127.0.0.1:${llmPort}/v1`,
|
|
280
|
+
'--mcp', 'helper', '--ptc'], {
|
|
281
|
+
cwd: dir,
|
|
282
|
+
env: { ...process.env, OPENAI_API_KEY: TEST_KEY, CAR_ALLOW_ENV_CREDENTIALS: '1', CAR_OTEL_ENDPOINT: `http://127.0.0.1:${otlpPort}` },
|
|
283
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
284
|
+
})
|
|
285
|
+
let out = ''
|
|
286
|
+
let err = ''
|
|
287
|
+
child.stdout.on('data', d => { out += d })
|
|
288
|
+
child.stderr.on('data', d => { err += d })
|
|
289
|
+
const exitDone = new Promise<number>(resolve => child.on('exit', c => resolve(c ?? -1)))
|
|
290
|
+
const code = await Promise.race([exitDone, new Promise<number>(r => setTimeout(() => r(-999), 60_000))])
|
|
291
|
+
llmServer.close()
|
|
292
|
+
otlp.close()
|
|
293
|
+
|
|
294
|
+
assert.equal(code, 0, `exit=${code}\nSTDOUT:\n${out}\nSTDERR:\n${err}`)
|
|
295
|
+
assert.match(out, /turnEnd=completed/)
|
|
296
|
+
// 工具结果含回显 traceparent(假 MCP server 捕获 _meta)——桥内与直调两条路径
|
|
297
|
+
const toolMsgs = llmBodies.flatMap(b => b.messages).filter((m: any) => m.role === 'tool')
|
|
298
|
+
const echoed: string[] = []
|
|
299
|
+
for (const m of toolMsgs) {
|
|
300
|
+
const s = JSON.stringify(m.content)
|
|
301
|
+
for (const g of s.matchAll(/tp:(00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2})/g)) echoed.push(g[1]!)
|
|
302
|
+
}
|
|
303
|
+
assert.ok(echoed.length >= 2, `两条 MCP 路径均回显 traceparent(${JSON.stringify(echoed)})`)
|
|
304
|
+
|
|
305
|
+
// OTLP 出站对拍(三点闭合:回显 traceparent ↔ 出站 car.mcp.tool spanId ↔ 同 traceId)
|
|
306
|
+
await new Promise(r => setTimeout(r, 300))
|
|
307
|
+
const spans = otlpBodies.flatMap(b => b.scopeSpans[0].spans)
|
|
308
|
+
const turnSpan = spans.find((s: any) => s.name === 'car.turn')
|
|
309
|
+
assert.ok(turnSpan, 'turn span 出站')
|
|
310
|
+
const mcpSpans = spans.filter((s: any) => s.name === 'car.mcp.tool')
|
|
311
|
+
assert.equal(mcpSpans.length, 2, '两次 MCP 外呼两个 client span')
|
|
312
|
+
for (const s of mcpSpans) {
|
|
313
|
+
assert.equal(s.parentSpanId, turnSpan.spanId, 'client span 父 = turn')
|
|
314
|
+
assert.equal(s.traceId, turnSpan.traceId, '同 trace')
|
|
315
|
+
}
|
|
316
|
+
const outSpanIds = new Set(mcpSpans.map((s: any) => s.spanId))
|
|
317
|
+
const outTraceId = turnSpan.traceId
|
|
318
|
+
for (const tp of echoed) {
|
|
319
|
+
const p = parseTraceparent(tp)!
|
|
320
|
+
assert.ok(p, `回显 traceparent 可解析:${tp}`)
|
|
321
|
+
assert.equal(p.traceId, outTraceId, '回显 traceId == 出站 turn traceId(端到端同 trace)')
|
|
322
|
+
assert.ok(outSpanIds.has(p.parentSpanId), '回显 spanId == 出站 car.mcp.tool spanId(client span 对拍闭合)')
|
|
323
|
+
}
|
|
324
|
+
// car.ptc relay span:挂链 turn + worker 计时窗口(程序含 60ms sleep)
|
|
325
|
+
const ptcSpan = spans.find((s: any) => s.name === 'car.ptc')
|
|
326
|
+
assert.ok(ptcSpan, 'car.ptc relay span 出站')
|
|
327
|
+
assert.equal(ptcSpan.parentSpanId, turnSpan.spanId, 'car.ptc 挂 turn')
|
|
328
|
+
assert.equal(ptcSpan.traceId, turnSpan.traceId, 'car.ptc 同 trace')
|
|
329
|
+
const durMs = (Number(ptcSpan.endTimeUnixNano) - Number(ptcSpan.startTimeUnixNano)) / 1e6
|
|
330
|
+
assert.ok(durMs >= 50 && durMs < 5000, `worker 计时窗口 ≈ 程序 sleep(${durMs.toFixed(1)}ms)`)
|
|
331
|
+
assert.equal(ptcSpan.status.code, 'STATUS_CODE_UNSET', 'ok 路径无 exception')
|
|
332
|
+
// 零内容口径:出站体无会话内容(提示词/工具结果文本/程序体不出站)
|
|
333
|
+
const all = JSON.stringify(otlpBodies)
|
|
334
|
+
assert.equal(/uppercase please|大写工具|setTimeout/.test(all), false, 'span 出站体零内容')
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
// ==================== E2E-B:mcp-serve 收 traceparent 挂链(D-30 生产调用点) ====================
|
|
339
|
+
|
|
340
|
+
test('S42 E2E-B: mcp-serve——_meta.traceparent 合法挂链远端 trace;畸形 fail-open 新 trace', async () => {
|
|
341
|
+
await withTempDir('mcp-serve', async dir => {
|
|
342
|
+
const traceBodies: any[] = []
|
|
343
|
+
let done: (v: void) => void = () => {}
|
|
344
|
+
const closed = new Promise<void>(r => { done = r })
|
|
345
|
+
const server: Server = createServer((req, res) => {
|
|
346
|
+
let body = ''
|
|
347
|
+
req.on('data', c => { body += c })
|
|
348
|
+
req.on('end', () => {
|
|
349
|
+
if (req.url === '/v1/traces') traceBodies.push(JSON.parse(body))
|
|
350
|
+
res.writeHead(200).end()
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
|
|
354
|
+
const port = (server.address() as { port: number }).port
|
|
355
|
+
try {
|
|
356
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'mcp-serve'], {
|
|
357
|
+
cwd: dir,
|
|
358
|
+
env: { ...process.env, CAR_OTEL_ENDPOINT: `http://127.0.0.1:${port}`, CAR_OTEL_INTERVAL_MS: '0' },
|
|
359
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
360
|
+
})
|
|
361
|
+
let err = ''
|
|
362
|
+
child.stderr.on('data', d => { err += d })
|
|
363
|
+
child.on('exit', () => done())
|
|
364
|
+
const { deriveSessionId } = await import('../src/host/mappings.ts')
|
|
365
|
+
const sid = deriveSessionId('claude-code', 's42-link')
|
|
366
|
+
const remoteTraceId = 'c'.repeat(32)
|
|
367
|
+
const remoteSpanId = 'd'.repeat(16)
|
|
368
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's42-link' } } }) + '\n')
|
|
369
|
+
// 合法 traceparent → turn span 加入宿主 trace
|
|
370
|
+
child.stdin.write(JSON.stringify({
|
|
371
|
+
jsonrpc: '2.0', id: 2, method: 'tools/call',
|
|
372
|
+
params: {
|
|
373
|
+
name: 'session_turn',
|
|
374
|
+
arguments: { sessionId: sid, input: { events: [{ hostEvent: 'user_message', payload: { content: 'hi' } }, { hostEvent: 'turn_complete' }] } },
|
|
375
|
+
_meta: { traceparent: formatTraceparent(remoteTraceId, remoteSpanId) },
|
|
376
|
+
},
|
|
377
|
+
}) + '\n')
|
|
378
|
+
// 畸形 traceparent → fail-open 新 trace(不 fail-hard)
|
|
379
|
+
child.stdin.write(JSON.stringify({
|
|
380
|
+
jsonrpc: '2.0', id: 3, method: 'tools/call',
|
|
381
|
+
params: {
|
|
382
|
+
name: 'session_turn',
|
|
383
|
+
arguments: { sessionId: sid, input: { events: [{ hostEvent: 'user_message', payload: { content: 'ho' } }, { hostEvent: 'turn_complete' }] } },
|
|
384
|
+
_meta: { traceparent: 'not-a-traceparent' },
|
|
385
|
+
},
|
|
386
|
+
}) + '\n')
|
|
387
|
+
child.stdin.end()
|
|
388
|
+
let raceTimer: ReturnType<typeof setTimeout> | undefined
|
|
389
|
+
try {
|
|
390
|
+
await Promise.race([
|
|
391
|
+
closed,
|
|
392
|
+
new Promise<never>((_, rej) => { raceTimer = setTimeout(() => rej(new Error(`mcp-serve 超时\n${err}`)), 60_000) }),
|
|
393
|
+
])
|
|
394
|
+
} finally {
|
|
395
|
+
if (raceTimer) clearTimeout(raceTimer)
|
|
396
|
+
}
|
|
397
|
+
const spans = traceBodies.flatMap(b => b.scopeSpans[0].spans)
|
|
398
|
+
const turns = spans.filter((s: any) => s.name === 'car.turn')
|
|
399
|
+
assert.equal(turns.length, 2, '两次 session_turn 两个 turn span')
|
|
400
|
+
const linked = turns.find((s: any) => s.traceId === remoteTraceId)
|
|
401
|
+
assert.ok(linked, '合法 traceparent:turn span 加入宿主 trace(traceId 复用)')
|
|
402
|
+
assert.equal(linked.parentSpanId, remoteSpanId, 'parentSpanId = 远端 spanId')
|
|
403
|
+
const attrs = Object.fromEntries(linked.attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
404
|
+
assert.equal(attrs['car.turn_id'], 'T1', 'turnId 回填不受挂链影响')
|
|
405
|
+
assert.equal(attrs['car.outcome'], 'completed')
|
|
406
|
+
const fallback = turns.find((s: any) => s.traceId !== remoteTraceId)
|
|
407
|
+
assert.ok(fallback, '畸形 traceparent:fail-open 新 trace')
|
|
408
|
+
assert.equal('parentSpanId' in fallback, false, 'fail-open 无 parent(新独立 trace)')
|
|
409
|
+
assert.equal(/hi|ho|user_message/.test(JSON.stringify(traceBodies)), false, 'span 出站体零内容')
|
|
410
|
+
} finally {
|
|
411
|
+
server.closeAllConnections()
|
|
412
|
+
server.close()
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
})
|