Skip to content

fix(service-automation): 降级注册那条 warn 不再插值 provider 的 reason,cause 走结构化 meta (#5660) - #5759

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5660-degraded-register-cause
Aug 6, 2026
Merged

fix(service-automation): 降级注册那条 warn 不再插值 provider 的 reason,cause 走结构化 meta (#5660)#5759
os-zhuang merged 1 commit into
mainfrom
claude/issue-5660-degraded-register-cause

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #5660

按分诊座位 2026-08-05 23:55Z 裁定的 A 路执行。

前提重验(立单行号已漂,结论成立)

立单基于 5b60b3669engine.ts:1659;#5725 / #5738 合入同包之后,当前 origin/main
(fce4c7385)上该方法在 engine.ts:1714,代码逐字未变:

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}`);
}

issue 正文的四条断言逐条对上:

  1. 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 给的任何文本。
  2. 它在常见分支上先发生 —— degradeConnectorInstance 先调
    engine.registerDegradedConnector(...)(plugin.ts:1332),之后才打自己那两条;finding(service-automation): connector 降级路径(#3017)还有两处 ${err.message} 单行插值,是 #5575 之外的第三个接缝 #5636 修的
    那条 warn 在同一个 trycatch 里,这条在 try 成功时打。
  3. 危害机制同 finding(service-automation): connector 降级路径(#3017)还有两处 ${err.message} 单行插值,是 #5575 之外的第三个接缝 #5636 —— warn 送 stdout;serve 的启动静默窗口只包 process.stdout.write;
    冷启动会走到(materializeDeclaredConnectors(ctx, { fatal: true }) 是降级不抛错);
    BootLogCapture.offer() 只保留 classifyBootLogLine 能找到级别头的物理行(该文件
    BOOT_LOG_CAPTURE_LIMIT = 256 * 1024总量上限,不是逐行上限 —— 下面 meta 里多带一份
    文本的体积顾虑因此可以排除)。
  4. 包外零调用 —— 全仓 grep(排除 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 后确认的):

this.logger.warn(
    `Connector registered DEGRADED: ${parsed.name} (origin: ${origin}) — no actions and no handlers ` +
        `until its upstream is reachable; a connector_action dispatching to it fails with the stored ` +
        `reason, and the materializer retries with backoff (#3017).`,
    cause === undefined
        ? { degradedReason: reason }
        : { degradedReason: reason, ...describeThrownForLog(cause) },
);

message 由构造保证无换行:nameConnectorSchema^[a-z_][a-z0-9_]*$ 约束
(connector.zod.ts:583),origin 是枚举,其余是字面量 —— 不是「大概不会有换行」。

meta 形状的取舍(#5660 正文把这一步留给实现定,这是定的理由)

  • degradedReason 恒定存在,不是「只在没有 cause 时才带」。它是这次注册存进 husk 的
    那段文本,而 error/issues抛出值的渲染 —— 两件不同的事实。今天唯一的调用点从前者
    派生后者所以两者重合,但让记录形状去依赖这个巧合,代价是一条 degradedReason 过滤条件会
    漏掉「恰好传了 cause」的那些记录;将来传摘要的调用点也会静默丢信息。体积顾虑已由上面
    256 KiB 是总量上限排除。
  • 字段名照 finding(core): ObjectLogger 的脱敏表按子串匹配,一个叫 keys 的普通字段会被整块换成 ***REDACTED*** #5573 挑过:ObjectLoggerpassword / 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 读真字节(spy process.stdout.write),
不 mock logger:

  • 多行 provider reason 端到端(真 LiteKernel 冷启动 + 降级)后不进 message:msg 无换行、
    不含 ECONNREFUSED / hint 两行的内容,degradedReasonerror 都逐字等于那段多行文本,
    issues 缺席,且 stdout 上每一行都能被 classifyBootLogLine 的判据分类(没有会被启动
    缓冲丢掉的续行);
  • pretty 格式(CLI 实际用的那个)同样只有一条带头的行,且 cause 与 hint 都在那一行上;
  • warn(message, meta)元数为 2(契约里 warnError 位);
  • API 面 degradedReason 逐字保留(getConnectorDescriptors()getConnectorDegradedReason()
    双向);
  • 嵌套 cause 被携带但今天不被渲染(上面那条如实报告的钉子);
  • 两参调用(省略 origincause)仍报 degradedReason、不臆造 error/issues ——
    同时钉住可选参数非 breaking;
  • ConnectorSchema.safeParse 的真 ZodError 作 cause 时走 issues 分支、仍是一行、
    key 名不被脱敏成 REDACTED

反向验证(先预测方向再跑,方向与 #5636 同向但结论更窄)

预测:把 engine.ts 那条 warn 还原成插值形状,新测试转红;还原后实测

× a multi-line provider reason never reaches the log message
× renders as a single head-bearing line in `pretty` too
× calls warn(message, meta) — `warn` has no Error slot
× the error's own nested `cause` is carried, not yet rendered
× a two-argument call still reports the reason, in meta
× a validation rejection as `cause` renders as `issues`, on one line
 Tests  6 failed | 2 passed (8)

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。报「所有事实全丢」
会是更整齐的故事,也会是错的。

命令与实测输出

$ pnpm --filter '@objectstack/service-automation^...' build          # 新 worktree 先建依赖
$ pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 \
      src/degraded-register-cause.test.ts
 Test Files  1 passed (1)
      Tests  8 passed (8)

$ pnpm --workspace-concurrency=2 --filter @objectstack/service-automation test   # 包内全量
 Test Files  64 passed (64)
      Tests  762 passed (762)

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 掉在 pristine origin/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。

…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.
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 6, 2026 4:03am

Request Review

@github-actions github-actions Bot added the size/l label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation.

5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via @objectstack/service-automation)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-automation)
  • content/docs/plugins/packages.mdx (via @objectstack/service-automation)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-automation)
  • content/docs/releases/v9.mdx (via @objectstack/service-automation)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 6, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 6, 2026 04:15
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit f205c32 Aug 6, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5660-degraded-register-cause branch August 6, 2026 04:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants