fix(service-automation): connector 降级路径的两条日志改用结构化 meta (#5636) - #5662
Merged
Conversation
`degradeConnectorInstance`(#3017 的降级/重试路径)有两条记录报告的是外来失败, 却把它插进了日志 message —— 与 #5048(flow 绑定)、#5575(`fail()`)同一类,是那 两单范围之外的第三个接缝: • husk 注册失败(`warn`):`err` 来自 `registerDegradedConnector` → `ConnectorSchema.parse`,catch 注释自己写着「the entry's def no longer parses」,即预期接到的正是多行 `ZodError.message`(第一行只有一个 `[`)。 • 降级公告(`error`):文本是 `ConnectorUpstreamUnavailableError.message`,由 第三方 provider factory 构造(ADR-0097 鼓励第三方去写),spec 不约束其文本。 这条 `warn` 的下游与 #5575 的 `error` 不同,且是实测的:`warn` 走 stdout,`serve` 的启动静默窗口只包了 `process.stdout.write`,而冷启动的 `materializeDeclaredConnectors(ctx, { fatal: true })` 遇到上游不可达是降级不抛错, 所以它在窗口内就跑。`BootLogCapture.offer()` 只保留能被 `classifyBootLogLine` 找到 等级头的物理行 —— 对一份 13 行的插值 dump 实测:保留 1 行(止于 `[` 的头行)、丢弃 12 行,唯一留下的那行不含任何事实。即 cloud#971 的原始形态。 两条都复用同包 `describeThrownForLog`:message 单行自足,cause 走结构化 meta。位置 按 `Logger` 契约核实后区分 —— `warn(message, meta?)` 用第二参, `error(message, error?, meta?)` 用第三参。 `degradedReason`(`GET /connectors` 与 `connector_action` 被拒时的文本)刻意逐字不变: 它是人透过 JSON 读的字段。因此调用点同时传 `reason`(那段文本)与 `cause`(抛出值), 测试双向钉住这个分离。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BWS4heBoAitLmzCLhcYdbK
…rade-connector-cause
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 5, 2026 23:16
This was referenced Aug 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5636
前提重验:两处接缝原样成立
立单时基于
cc5b048a0,其后 PR #5639(#5575)已合入同文件,行号确实漂移了。对origin/main5b60b3669逐条核对后,两处接缝的代码形态一字未变,只是位置移到plugin.ts:1273-1275(husk 注册失败的warn)与plugin.ts:1288-1294(降级公告的error)。issue 描述的reason来源链也成立:plugin.ts:1187的reason: (err as Error).message把ConnectorUpstreamUnavailableError.message原样传进
degradeConnectorInstance,而该 message 由第三方 provider factory 构造 ——packages/spec/src/integration/connector-provider-errors.ts只定义错误类,构造函数收什么文本就是什么文本,不约束行数。
issue 提醒要实测而非照抄的两件事,都实测了:
warn的第二参就是meta——packages/spec/src/contracts/logger.ts声明warn(message, meta?),error(message, error?: Error, meta?)。只有error/fatal有
Error位。所以两条记录的 cause 落在不同参数位,代码里各自注释了原因。warn侧的 BootLogCapture 危害论证成立(与 finding(service-automation): connector 物化失败的fail()也是${err.message}单行插值,同 #5048 的类别、另一个接缝 #5575 相反,那一单被证伪) —— 见下。危害:这条
warn的下游与 #5575 的error不同,而且是量出来的ObjectLogger把warn送 stdout、error/fatal送 stderr,serve的启动静默窗口只包了
process.stdout.write。#5575 的接缝全是error,所以那一单的结论是「启动缓冲根本看不到」。这一条不同,四个条件都核实过:
warn→ stdout,缓冲确实看得到;materializeDeclaredConnectors(ctx, { fatal: true })遇到上游不可达是降级、不抛错(
plugin.ts:1180);serve.ts:710在 config 加载前接管 stdout,直到 banner 打印才恢复;
BootLogCapture.offer()只在classifyBootLogLine能在该物理行上找到时间戳 + 等级头时才保留它,续行直接丢弃。
拿真
ObjectLogger+ 真BootLogCapture对一份 13 行的插值 ZodError dump 跑了一遍:唯一被留下的那一行不含任何事实 —— 它止于 Zod 的
[。这是 cloud#971 的原始形态,不只是「不好解析」。
error那一条走 stderr、不经缓冲,危害是 #5575 那一串按行消费者(文件sink、
docker logs/journald 送采集、grep ERROR):一条诊断散成 N 个无法归属的碎片。改法
两条都复用同包
thrown-cause-diagnostics.ts的describeThrownForLog(#5572/#5639落地):message 是不含换行的自足句子,cause 走结构化 meta,位置按上面核实过的契约区分。
没有新增 meta 字段名,因此不涉及 #5573 的子串脱敏(
issues/error是既有字段,被拒键名仍在
unrecognized里)。刻意没有改的一件事(取舍,附代码证据)
degradedReason——engine.ts:1659存进 registry、经getConnectorDescriptors()出GET /connectors、并被getConnectorDegradedReason()用于connector_action被拒时的文本 —— 仍然逐字保留 provider 自己的 message,包含换行。它是人透过 JSON 读的字段,
不经按行切分的消费者;重塑它属于另一次契约变更,不该搭在这一单里。
因此调用点同时传两个值,而不是把
reason换成cause:connector-degrade-cause.test.ts双向钉住了这个分离:一条断言degradedReason与getConnectorDegradedReason()都等于那段多行原文,另一条断言日志 message 不含换行。测试
新增
packages/services/service-automation/src/connector-degrade-cause.test.ts(9 例),结构与 #5639 的
connector-fail-cause.test.ts对齐:端到端跑真 logger、读真字节,不信 spy 单独作证。
connector-degrade-cause+connector-fail-cause+connector-materialization三件套:Tests 53 passed (53)。反向验证:方向先预测,再跑
预测(跑之前写下):把两处插值还原成修前形状,新增用例里读日志形状的那些必须变红
(插值多行 cause 会让
pretty下不止一条物理行、msg含换行、meta 里没有 cause),而不读日志形状的那些必须保持绿。这是最常见的「红」方向,不是 #5018 那种反转。
实测:
Tests 5 failed | 4 passed (9)。一处如实订正:我预测的是「6 红 / 3 绿」,实测「5 红 / 4 绿」。每一条用例的方向
都与预测一致(读日志形状的全红、不读的全绿),差的是我数错了有几条读日志形状 —— 9 例里
只有 5 例读,另外 4 例分别是
degradedReason逐字保留、重试簿记存活、以及两条本地「代价度量」复现(它们不经接缝,还原与否都绿)。记在这里而不是改成 5/4 假装当初就这么
预测。
用例怎么触到
warn那一条不 stub engine:
buildDegradedHuskDef把entry.type经一次 cast 抄进 husk def,所以声明一个
ConnectorTypeSchema枚举外的type(如mcp_server)就让registerDegradedConnector里的ConnectorSchema.parse真抛 ZodError —— 正是那个 catch注释写的「the entry's def no longer parses」。
门检查
字节纪律,两次命中,都如实记下:
写文件时它被落成了真 ESC 字节;
grep -naP自查抓到后用脚本按字节替换回转义序列,源码现在干净(
check:nul-bytes与自查都绿)。在写上面第 1 条、描述这个字节的那句话里落进去的。回读存档正文时发现,已改成纯文字
描述。这正是
.claude/skills/**的 markdown 不被任何门禁扫描 —— check:nul-bytes 只看 JS/TS,check:doc-authoring 的 ROOTS 不含 .claude/ #4890 的原样重演(在写「禁止真 NUL」这条规则的同时把真 NUL 写进了SKILL.md),也是 check:nul-bytes 只扫 NUL(0x00)—— 0x01-0x08 等控制字节不在扫描面,#5140 实测一个 0x01 会从 NUL-only 修复下溜走 #5157 的教训:写关于控制字符的东西时最容易把它写成真字节,所以正文必须回读校验,不能只信写出去的那一份。
消费半径扫过
retrying with backoff, attempt/register degraded husk/degradedReason在framework 全仓、
../cloud、../objectui里都没有其他消费者(唯一命中就是本文件与新用例),所以改 message 文本不牵动任何 fixture 或断言。
范围外发现(已按 Prime Directive #10 立单,未在本 PR 修)
搜重后确认无同题 issue,两单都 unassigned、
finding标签、无pm:queue:engine.ts:1659的registerDegradedConnector自己那条warn仍把同一个reason插进 message。它在调用顺序上先发生,而且在 husk 注册成功的常见分支上就会打,所以本 PR 修完之后,首次降级的默认路径上还留着一条会溢出的 warn。没有一起修,
是因为它在另一个文件、另一个契约上:
registerDegradedConnector的签名只收reason: string(没有抛出值可用),而那个字符串同时是 API 可见的degradedReason——修法要先在「加
cause?参数」和「reason 降为 meta 字段」之间定一个,不是零决策,issue 里把两条路和倾向写清了。
${err.message}插进日志 message —— 其中两条是耐久性诊断,正是最不该被切碎的记录 #5661 —— 同文件另外三处外来${err.message}插值(plugin.ts:454/592-594/931-936),三单(bug(service-automation): flow 绑定失败的告警把 Zod issue 数组塞进单行日志,读者只拿到一个孤零零的[#5048/finding(service-automation): connector 物化失败的fail()也是${err.message}单行插值,同 #5048 的类别、另一个接缝 #5575/finding(service-automation): connector 降级路径(#3017)还有两处${err.message}单行插值,是 #5575 之外的第三个接缝 #5636)都没覆盖。其中两条是耐久性诊断,注释自己写明「suspended runs will NOT survive a restart」「will hang indefinitely」并特意选了
error级 —— 最不该被切碎的记录。issue 里也说明了为什么plugin.ts:191(是throw,按 finding(service-automation): connector 物化失败的
fail()也是${err.message}单行插值,同 #5048 的类别、另一个接缝 #5575 的界线应保持现状)和:729(debug,且文本是我们自己的)不含在内。