做 #5636 时在同一条 #3017 降级路径上扫到的,但在另一个文件 (engine.ts)、另一个方法里,契约也不同,因此不在那一单范围内,按 Prime Directive #10 单开。
现象
packages/services/service-automation/src/engine.ts:1659(origin/main 5b60b3669):
registerDegradedConnector ( def : Connector , reason : string , origin : ConnectorOrigin = 'declarative' ) : void {
const parsed = ConnectorSchema . parse ( def ) ;
this . assertSameOriginOrFree ( parsed . name , origin ) ;
this . connectors . set ( parsed . name , { def : parsed , handlers : { } , origin, state : 'degraded' , degradedReason : reason } ) ;
this . logger . warn ( `Connector registered DEGRADED: ${ parsed . name } (origin: ${ origin } ) — ${ reason } ` ) ;
}
那个 reason 参数就是 #5636 处理的同一个值:plugin.ts 的 degradeConnectorInstance 把
ConnectorUpstreamUnavailableError.message 原样传进来,而该 message 由第三方 provider
factory 构造(ADR-0097 明确鼓励第三方去写 provider factory;spec 只定义错误类、不约束
文本),完全可以是多行。
为什么它比 #5636 修掉的那两处更容易被忽略
调用顺序决定了它先 发生,而且发生在常见分支 上:
degradeConnectorInstance → engine.registerDegradedConnector(husk, info.reason, …)
→ 这一条 warn (husk 注册成功时就会打,也就是每次首次降级都打);
只有 husk 注册抛错 时才走到 plugin 侧那条 warn(finding(service-automation): connector 降级路径(#3017)还有两处 ${err.message} 单行插值,是 #5575 之外的第三个接缝 #5636 已修);
然后才是 plugin 侧的降级公告 error(finding(service-automation): connector 降级路径(#3017)还有两处 ${err.message} 单行插值,是 #5575 之外的第三个接缝 #5636 已修)。
也就是说 #5636 修完之后,首次降级的默认路径上仍然留着一条会溢出的 warn。
危害机制(与 #5636 同一条,已实测)
ObjectLogger 把 warn 送 stdout ,serve 的启动静默窗口只包了
process.stdout.write,窗口从 config 加载前一直开到 banner 打印;
冷启动的 materializeDeclaredConnectors(ctx, { fatal: true }) 遇到上游不可达是降级、
不抛错 ,所以这条路径在窗口内就会跑;
BootLogCapture.offer() 只在 classifyBootLogLine 能在物理行上找到 <ts> <LEVEL> 头时
才保留该行,续行直接丢弃 。
#5636 的 PR 里对一份 13 行的插值 ZodError dump 实测:写出 13 行,缓冲保留 1 行(止于
Zod [ 的头行)、丢弃 12 行。这里的载荷不是 ZodError 而是 provider 的 message,机制一样。
即 cloud#971 的原始形态。
可达性(为什么是 finding 而不是 bug)
与 #5575 / #5636 同样的理由:今天 packages/connectors/*/src 里 ConnectorUpstreamUnavailableError
的 message 在 openapi / mcp / rest / slack 里都是我们自己写的单行文本。第一个把上游 SDK 的
多行错误塞进该错误类的第三方 provider 插件会撞上。
修法上需要先定的一件事(不是零决策)
与 #5636 的两处不同,这里没有 抛出值可用:registerDegradedConnector 的签名只收
reason: string。而且这个字符串同时是 degradedReason,会经 GET /connectors 和
connector_action 被拒时的文本出去 —— #5636 刻意保持它逐字不变(人透过 JSON 读,不经按行
切分的消费者)。所以至少两条路:
倾向 A :它与 #5048 / #5575 / #5636 建立的形状一致(抛出值一路带到报告点,不在边界先
拍平成字符串),而且 ConnectorUpstreamUnavailableError 本身还带一个 cause(底层 connect
错误),A 之后才有可能把它也渲染出来。B 更小,但把「reason 是给人读的文本」和「日志要结构化」
这两件事压在同一个字符串上,下一个接缝还会再遇到。
关联
#5636 / PR(plugin 侧两处,同一条路径)、#5575 / PR #5639 、#5048 / PR #5572 、#5573 (脱敏按
子串匹配)、#4632 、#3017 (降级/重试本身)、cloud#971、ADR-0097。
做 #5636 时在同一条 #3017 降级路径上扫到的,但在另一个文件(
engine.ts)、另一个方法里,契约也不同,因此不在那一单范围内,按 Prime Directive #10 单开。现象
packages/services/service-automation/src/engine.ts:1659(origin/main5b60b3669):那个
reason参数就是 #5636 处理的同一个值:plugin.ts的degradeConnectorInstance把ConnectorUpstreamUnavailableError.message原样传进来,而该 message 由第三方 providerfactory 构造(ADR-0097 明确鼓励第三方去写 provider factory;spec 只定义错误类、不约束
文本),完全可以是多行。
为什么它比 #5636 修掉的那两处更容易被忽略
调用顺序决定了它先发生,而且发生在常见分支上:
degradeConnectorInstance→engine.registerDegradedConnector(husk, info.reason, …)→ 这一条 warn(husk 注册成功时就会打,也就是每次首次降级都打);
${err.message}单行插值,是 #5575 之外的第三个接缝 #5636 已修);${err.message}单行插值,是 #5575 之外的第三个接缝 #5636 已修)。也就是说 #5636 修完之后,首次降级的默认路径上仍然留着一条会溢出的 warn。
危害机制(与 #5636 同一条,已实测)
ObjectLogger把warn送 stdout,serve的启动静默窗口只包了process.stdout.write,窗口从 config 加载前一直开到 banner 打印;materializeDeclaredConnectors(ctx, { fatal: true })遇到上游不可达是降级、不抛错,所以这条路径在窗口内就会跑;
BootLogCapture.offer()只在classifyBootLogLine能在物理行上找到<ts> <LEVEL>头时才保留该行,续行直接丢弃。
#5636 的 PR 里对一份 13 行的插值 ZodError dump 实测:写出 13 行,缓冲保留 1 行(止于
Zod
[的头行)、丢弃 12 行。这里的载荷不是 ZodError 而是 provider 的 message,机制一样。即 cloud#971 的原始形态。
可达性(为什么是 finding 而不是 bug)
与 #5575 / #5636 同样的理由:今天
packages/connectors/*/src里ConnectorUpstreamUnavailableError的 message 在 openapi / mcp / rest / slack 里都是我们自己写的单行文本。第一个把上游 SDK 的
多行错误塞进该错误类的第三方 provider 插件会撞上。
修法上需要先定的一件事(不是零决策)
与 #5636 的两处不同,这里没有抛出值可用:
registerDegradedConnector的签名只收reason: string。而且这个字符串同时是degradedReason,会经GET /connectors和connector_action被拒时的文本出去 —— #5636 刻意保持它逐字不变(人透过 JSON 读,不经按行切分的消费者)。所以至少两条路:
cause?: unknown,让warn用describeThrownForLog(cause)走 meta,reason与degradedReason一字不动。调用点(只有 plugin.ts 一处)顺手把err传进来。reason,只报 name/origin,reason 作为 meta 字段(
{ degradedReason: reason },注意字段名不得含key/token/secret/password子串,见 finding(core): ObjectLogger 的脱敏表按子串匹配,一个叫
keys的普通字段会被整块换成***REDACTED***#5573)。不动签名。倾向 A:它与 #5048 / #5575 / #5636 建立的形状一致(抛出值一路带到报告点,不在边界先
拍平成字符串),而且
ConnectorUpstreamUnavailableError本身还带一个cause(底层 connect错误),A 之后才有可能把它也渲染出来。B 更小,但把「reason 是给人读的文本」和「日志要结构化」
这两件事压在同一个字符串上,下一个接缝还会再遇到。
关联
#5636 / PR(plugin 侧两处,同一条路径)、#5575 / PR #5639、#5048 / PR #5572、#5573(脱敏按
子串匹配)、#4632、#3017(降级/重试本身)、cloud#971、ADR-0097。