fix(service-automation): 降级注册那条 warn 不再插值 provider 的 reason,cause 走结构化 meta (#5660) - #5759
Merged
Merged
Conversation
…eta, message stays one line (#5660) `AutomationEngine.registerDegradedConnector` interpolated the caller-supplied `reason` into its own warn: this.logger.warn(`Connector registered DEGRADED: ${name} (origin: ${origin}) - ${reason}`); That text is not ours. The only caller — `degradeConnectorInstance` — passes `ConnectorUpstreamUnavailableError.message`, constructed by a third-party provider factory (ADR-0097 invites people to write them; the spec constrains the `code`, never the text), so an upstream SDK's multi-line failure landed inside the message verbatim. `ObjectLogger.write()` emits one `<ts> <LEVEL> …` head per call, so a message carrying newlines becomes several physical lines and only the first is a record. This is the fourth seam of the family #5048, #5575 and #5636 closed, and what makes it worth its own fix is ORDER, not severity: it fires BEFORE both of #5636's records, and it fires on the DEFAULT branch — #5636's warn sits in a catch (reached only when the husk itself fails to parse), this one runs when that try SUCCEEDS, i.e. on every first degrade of every instance. So after #5636 the ordinary cold-boot degrade still spilled. The downstream is #5636's, measured there: warn goes to stdout, `serve`'s boot-quiet window wraps `process.stdout.write` only, a cold boot reaches this seam inside that window (`materializeDeclaredConnectors(ctx, { fatal: true })` degrades rather than throwing), and `BootLogCapture.offer()` DROPS any physical line `classifyBootLogLine` finds no level head on. `registerDegradedConnector` takes an optional `cause?: unknown` after the defaulted `origin`, so every pre-existing call shape still compiles (a new test is one). The message is now self-sufficient and newline-free by construction (`name` is `^[a-z_][a-z0-9_]*$`, `origin` is an enum) and the facts travel in `warn`'s meta: - `degradedReason` — always present, the text this registration STORED on the husk. Named past `ObjectLogger`'s substring redactor (#5573). - the thrown value's own rendering (`error` or `issues`, via `describeThrownForLog`) — only when a cause was supplied. It describes the FAILURE where `degradedReason` describes the REGISTRATION; the two coincide for today's single caller but the record's shape does not depend on that. Deliberately unchanged: `reason`, and therefore the descriptor's `degradedReason` — what `GET /connectors` shows and what a `connector_action` refusal quotes — stays verbatim, newlines included (#5636 made the same call one layer up), pinned from both sides. And `describeThrownForLog` is NOT widened: `ConnectorUpstreamUnavailableError`'s own nested `cause` is now carried here but still not rendered, which a test states rather than glosses over — widening it changes a helper four seams share. The reverse verification predicted the plain red direction and measured a narrower loss than #5636's: a ZodError dump opens on a bare `[` so its one retained line held no facts, whereas here the reason's first line survives and the `cause:`/`hint:` lines — the address refused and the thing to check — are what the buffer drops. 3 lines in, 1 retained, 2 dropped.
|
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 6, 2026 04:15
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 #5660
按分诊座位 2026-08-05 23:55Z 裁定的 A 路执行。
前提重验(立单行号已漂,结论成立)
立单基于
5b60b3669的engine.ts:1659;#5725 / #5738 合入同包之后,当前origin/main(
fce4c7385)上该方法在engine.ts:1714,代码逐字未变:issue 正文的四条断言逐条对上:
reason就是 finding(service-automation): connector 降级路径(#3017)还有两处${err.message}单行插值,是 #5575 之外的第三个接缝 #5636 处理的同一个值 ——plugin.ts:1235传的是(err as Error).message,即
ConnectorUpstreamUnavailableError.message;该类在packages/spec/src/integration/connector-provider-errors.ts只约束
code,构造函数收下 factory 给的任何文本。degradeConnectorInstance先调engine.registerDegradedConnector(...)(plugin.ts:1332),之后才打自己那两条;finding(service-automation): connector 降级路径(#3017)还有两处${err.message}单行插值,是 #5575 之外的第三个接缝 #5636 修的那条
warn在同一个try的 catch 里,这条在try成功时打。${err.message}单行插值,是 #5575 之外的第三个接缝 #5636 ——warn送 stdout;serve的启动静默窗口只包process.stdout.write;冷启动会走到(
materializeDeclaredConnectors(ctx, { fatal: true })是降级不抛错);BootLogCapture.offer()只保留classifyBootLogLine能找到级别头的物理行(该文件BOOT_LOG_CAPTURE_LIMIT = 256 * 1024是总量上限,不是逐行上限 —— 下面 meta 里多带一份文本的体积顾虑因此可以排除)。
node_modules/.git)只有 4 处命中:engine.ts的定义、plugin.ts:1332的唯一调用点,以及 finding(service-automation): connector 降级路径(#3017)还有两处${err.message}单行插值,是 #5575 之外的第三个接缝 #5636 的测试与 changeset 里的两处文字引用。所以可选参数在事实上也不只是「理论非 breaking」。
改法
registerDegradedConnector签名末尾加可选cause?: unknown(在已有默认值的origin之后,既有调用形状全部照旧编译 —— 新测试里就有一个两参调用在钉这件事)。message 变成单行自足,
事实走
warn(message, meta?)的第二参(Logger契约里warn没有Error位,只有error/fatal有;这一点是核对packages/spec/src/contracts/logger.ts后确认的):message 由构造保证无换行:
name受ConnectorSchema的^[a-z_][a-z0-9_]*$约束(
connector.zod.ts:583),origin是枚举,其余是字面量 —— 不是「大概不会有换行」。meta 形状的取舍(#5660 正文把这一步留给实现定,这是定的理由)
degradedReason恒定存在,不是「只在没有 cause 时才带」。它是这次注册存进 husk 的那段文本,而
error/issues是抛出值的渲染 —— 两件不同的事实。今天唯一的调用点从前者派生后者所以两者重合,但让记录形状去依赖这个巧合,代价是一条
degradedReason过滤条件会漏掉「恰好传了 cause」的那些记录;将来传摘要的调用点也会静默丢信息。体积顾虑已由上面
256 KiB 是总量上限排除。
keys的普通字段会被整块换成***REDACTED***#5573 挑过:ObjectLogger按password/token/secret/key子串递归脱敏,
degradedReason一个都不含。唯一调用点顺手把
info.cause传了进来(该字段 #5636 已经存在,无需新增)。刻意没做的两件事
reason/degradedReason一字不动。GET /connectors展示的、connector_action被拒时引用的那段文本仍逐字保留 provider 自己的 message,换行包含在内 —— 它是人透过 JSON 读的,
不经按行切分的消费者(finding(service-automation): connector 降级路径(#3017)还有两处
${err.message}单行插值,是 #5575 之外的第三个接缝 #5636 在上一层做了同样的判断)。测试从两个方向钉住这个分离。describeThrownForLog。这是 A 路「附加价值」那一条的如实报告:ConnectorUpstreamUnavailableError自带一个cause(底层 connect 错误),把抛出值本身一路带过来才使渲染它成为可能;但该 helper 目前只读
.message/.issues,所以嵌套 cause今天还不会出现在记录里。这一点由一条专门的测试钉住(断言 meta 里
error是外层 message、且整个 meta 不含内层的
ECONNREFUSED),而不是含混带过 —— 扩宽它是改四个接缝共用的 helper,不是这个接缝该顺手做的决定。
测试
新增
packages/services/service-automation/src/degraded-register-cause.test.ts,8 例,照 #5662 / #5738 先例用真
ObjectLogger读真字节(spyprocess.stdout.write),不 mock logger:
LiteKernel冷启动 + 降级)后不进 message:msg无换行、不含
ECONNREFUSED/hint两行的内容,degradedReason与error都逐字等于那段多行文本,issues缺席,且 stdout 上每一行都能被classifyBootLogLine的判据分类(没有会被启动缓冲丢掉的续行);
pretty格式(CLI 实际用的那个)同样只有一条带头的行,且 cause 与 hint 都在那一行上;warn(message, meta)的元数为 2(契约里warn无Error位);degradedReason逐字保留(getConnectorDescriptors()与getConnectorDegradedReason()双向);
origin与cause)仍报degradedReason、不臆造error/issues——同时钉住可选参数非 breaking;
ConnectorSchema.safeParse的真ZodError作 cause 时走issues分支、仍是一行、key 名不被脱敏成
REDACTED。反向验证(先预测方向再跑,方向与 #5636 同向但结论更窄)
预测:把
engine.ts那条 warn 还原成插值形状,新测试转红;还原后实测6 红 2 绿,与预测逐条一致。绿的两条正是应该绿的:API 面
degradedReason逐字那条(本单没改这个行为),以及本地量代价那条(它自己构造前后两种形状,不经
engine.ts)。那条本地量代价的测试里,结论刻意写窄了:#5636 的载荷是
ZodError.message(首行只有一个[),唯一被留下的行不含任何事实;这里的载荷是 provider 的散文,首行会活下来,丢的是它后面的
cause:/hint:两行 —— 也就是「哪个地址被拒」和「该去查什么」。实测 3 行进、1 行留、2 行丢,留下那行含
could not reach its MCP server、不含ECONNREFUSED 127.0.0.1:8931。报「所有事实全丢」会是更整齐的故事,也会是错的。
命令与实测输出
typecheck:该包无
typecheck脚本,在scripts/check-type-check-coverage.mjs里带 DEBT 条目(
errors: 2)。直接跑tsc --noEmit -p实测 5(2 在engine.test.ts,3 在nested-region-parity.test.ts),而把本分支 stash 掉在 pristineorigin/main上量也是 5—— delta 0,本单没有新增任何类型错误,那 3 处是先于本单存在的 ledger 漂移,已作为第二个
实测样本附在 #5278(该 finding 已入队)下,未在本 PR 里改动 ledger。
门(照 lint.yml 的 lint job 逐个列出跑全,不凭记忆挑)
pnpm lint✅ /check:slot-lookup✅ /check:query-options-erasure✅ /check:nul-bytes✅ /check:doc-authoring✅ /check:docs-audit-scope✅ /check:role-word✅ /check:adr-anchors✅ /check:org-identifier✅ /check:authz-resolver✅ /check:service-providers✅ /check:route-envelope✅ /check:error-code-casing✅ /check:wildcard-fallthrough✅ /check:init-service-contract✅ /check:durability-log-level✅ /check:startup-registry-verdict✅ /check:objectui-changeset✅ /check:release-notes✅ /check:release-body✅ /check:node-version✅ /check:workflow-status-functions✅ /check:published-files✅ /check:engine-double-contract✅ /check:resume-authority-declared✅,外加check:type-check-coverage✅ 与改动文件的 eslint ✅。控制字节:
check:nul-bytes在把新文件git add之后又跑了一遍(它只扫 tracked 文件,未 add 时看不见新文件 —— 这正是 #4890 那类盲区),并对四个改动文件做了超出门范围的自扫
(
grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]',无命中)。这条值得单独记:写这份测试时,
classifyLine里那个剥 ANSI 的正则确实被写成了裸 ESC 字节(
cat -v显示为 caret-左括号),已改回 unicode 转义的文本写法(反斜杠 + 小写 u + 001B)后重扫通过。本 PR 正文第一版也复发了同一件事 —— 同一句话里的转义又被物化成字节存进了 GitHub body,
读回来才发现,所以这一版改成用文字描述该转义、不再在正文里拼写它。#4890 说的「写规则本身的时候
最容易踩」在同一个任务里连中两次,现场留档在此。
未改动
content/docs/releases/(禁碰);.changeset/degraded-register-cause.md为 patch。