Skip to content

fix(service-automation): 从未 seal 的引擎首次执行 flow 时告警一次 (#4792) - #5595

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-4792-seal-warn-once
Aug 5, 2026
Merged

fix(service-automation): 从未 seal 的引擎首次执行 flow 时告警一次 (#4792)#5595
os-zhuang merged 1 commit into
mainfrom
claude/issue-4792-seal-warn-once

Conversation

@os-zhuang

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

Copy link
Copy Markdown
Contributor

Fixes #4792

前提核对(对当前 origin/main)

issue 写于 08-03,先核对现状再动手,前提成立:

  • packages/services/service-automation/src/engine.ts:1132private nodeTypeVocabularySealed = false 仍在,字段名未变;
  • engine.ts:3600 sealNodeTypeVocabulary() 是唯一执行 ADR-0018 节点类型校验的时刻;engine.ts:2022 registerFlow 只在 已 seal 后才即时校验;
  • plugin.ts:850/878AutomationServicePlugin 仍在 kernel:bootstrapped 调用 seal(插件路径必然早于任何 execute(),不会误报);
  • execute()没有任何人加过相关告警(grep never sealed / warnOnce / hasWarned 全无命中)。

即:直接 new AutomationEngine() 且不自己 seal 的嵌入式 host,至今仍是完全静默地拿不到该校验。

改动

execute() 在通过「flow 存在 + 未被禁用」两道门之后调用新的 warnIfNodeTypeVocabularyNeverSealed():词汇表未 seal 且本引擎还没说过,则打一条 warn,写明丢了什么(ADR-0018 校验从未运行,这些节点改为运行中 NO_EXECUTOR 失败)和怎么修(host 在插件贡献完执行器后调用 engine.sealNodeTypeVocabulary();插件路径在 kernel:bootstrapped 做这件事)。等级按 #4632 判为功能性降级 → warn

三个设计决定,都有代码证据:

  1. 去重按引擎实例(nodeTypeSealOmissionWarned 是实例字段,不是模块级):按租户/环境各建一个引擎的 host 是在每个引擎上都漏了这次调用,模块级 flag 只会报第一个跑起来的那个。
  2. 只报「缺了这次调用」,不报未知类型审计结果。 未 seal 的引擎按契约词汇表仍可增长,在那里断言「某类型没有执行器」正是 AGENTS.md「Startup registry reads」禁止的形态 —— 会被本次启动反驳、又已经写进日志收不回来,等于把 bug(service-automation): flow 节点类型校验跑在插件贡献的执行器注册之前 —— 每个 ADR-0019 approval flow 都被误报「will fail at execution time」 #4771 在嵌入式路径上重建一遍。「host 没调用过 seal」则是关于 host 的事实,后续任何注册都改不了它。需要审计结果又不想封闭词汇表的 host 用只读的 getUnknownNodeTypeAudit()
  3. 不顺带自动 seal(issue 留给接手者的二选一)。除了 issue 自己说的「两个答案」坏味道,代码证据更硬:engine.ts:2022 显示 seal 之后 registerFlow 转为即时校验,所以自动 seal 会让「先执行 flow、后注册插件执行器」的嵌入式 host(ADR-0018 允许词汇表开放,这完全合法)开始收到 bug(service-automation): flow 节点类型校验跑在插件贡献的执行器注册之前 —— 每个 ADR-0019 approval flow 都被误报「will fail at execution time」 #4771 那种「will fail at execution time」误报 —— 那正是 bug(service-automation): flow 节点类型校验跑在插件贡献的执行器注册之前 —— 每个 ADR-0019 approval flow 都被误报「will fail at execution time」 #4771 要删的东西。seal 的所有权语义因此未被改变(仍由 host 决定),不构成公共契约分叉,故按代码证据裁定而非上抛。

告警文本刻意避开 warnUnknownNodeTypesno registered executor or descriptor 字样:多处测试与日志过滤用这个子串计数「每 flow 的未知类型发现」,一条只是在谈论它们的行文不能被算成一条(第一版就撞上了,flow-node-type-audit.test.ts 的计数器同样读这个子串)。

消费半径与 fixture 处置

execute() 的调用者遍及全仓,因此按规则的消费半径扫 fixture,而不是只扫被改的包:全仓 new AutomationEngine( 的 51 处里,生产代码只有 plugin.ts(走 seal),其余全是测试。本包整套跑完暴露出 builtin/decision-branch-routing.test.ts 的 5 个用例 —— 它们是嵌入式 host,expect(warnings).toHaveLength(0) 表达的是「#4414 路由没有告警」。

处置选的是「补声明」而不是「过滤断言」:两处 harness 在注册完执行器/flow 后补 engine.sealNodeTypeVocabulary(),让 fixture 真正表达一个装配正确的 host,零容忍断言得以原样保留。若改成把新告警从断言里过滤掉,以后任何借用这个 harness 的测试都会连带对真实信号失明。

测试与反向验证

反向验证的方向是先预测再跑:

实验 预测 实际
删掉 execute() 里的调用(还原修复前) 3 个「期望有告警」的嵌入式用例红;2 个哨兵与 decision-branch-routing 保持绿 一致:Tests 3 failed / 18 passed
把 seal 判断取反(已 seal 才告警) 两条哨兵红(证明它们守的是 seal 状态而非字符串);补了 seal 的 5 个 routing 用例也应转红 一致:Tests 10 failed / 11 passed,含两条 SENTINEL 与 5 个 routing

第二个实验同时证明我给 decision-branch-routing.test.ts 补的 seal 是承重的,不是装饰。注意第 4 个新用例(「未注册/已禁用的 flow 名不触发」)断言的是「无告警」,所以在实验一里理应保持绿 —— 它确实绿,这与「修前红」的模板预期不同,如实记录。

跑过的命令(真实输出摘要):

  • pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2Test Files 59 passed (59) / Tests 713 passed (713)(707 旧 + 6 新)
  • 消费半径:plugin-approvals 446 / trigger-schedule 42 / trigger-record-change 55 / connector-mcp 23 / connector-rest 16 / connector-slack 8,全绿
  • 门:check:startup-registry-verdict(40 seam(s) … none recording a verdict the boot can contradict)、check:durability-log-levelcheck:nul-bytescheck:adr-anchorscheck:type-check-coverage 全过;改动文件 eslint 无输出
  • 类型:本包在 [P2] framework: 66 个包用 tsup 构建、无人做类型检查 —— 实测 18 个包共 380 处 code-tier 错误(#4118 的 framework 侧对应) #4311 DEBT 台账内、无 typecheck script,故直接 tsc --noEmit -p tsconfig.json 核对 —— 报错全部落在我未触碰的文件(engine.test.ts 的 TS2739 即台账所记、nested-region-parity.test.ts 的 TS2341),新增文件与 engine.ts 零报错

changeset:patch(用户可见的新告警)。未碰 wait-node / plugin.ts 的绑定日志面。构建过程中 packages/spec/authorable-surface.base.jsongen:schema 重新锚定,已还原、未提交。分支基于 81087877e;其后落在 main 的两个提交(#5577 / #5578)只动 .claude/agents/os-dev.mdpackages/client/vitest.integration.config.ts,与本改动零重叠,故未做无谓合并 —— PR CI 会校验合并结果。

Generated by Claude Code

… node-type vocabulary (#4792)

#4771 made sealNodeTypeVocabulary() the only moment the ADR-0018 node-type
check runs. AutomationServicePlugin seals at kernel:bootstrapped, so plugin
hosts are covered — but a host that constructs `new AutomationEngine()` and
never seals lost the check entirely, in silence, discoverable only by reading
a changeset. The first execute() on an unsealed engine now says so once.

- Once per engine INSTANCE, not per process: a host with one engine per
  tenant/environment omitted the call on each of them.
- The line reports the missing CALL, not the unknown-type audit: an unsealed
  engine's vocabulary can still grow by contract, so naming absent executors
  there would rebuild #4771's contradictable verdict inside the embedded path.
  getUnknownNodeTypeAudit() stays the read-only probe for hosts that want the
  findings without closing the vocabulary.
- It deliberately does NOT auto-seal: authority over "closed" stays with the
  host, and after a seal registerFlow validates inline — auto-sealing would
  hand the false "will fail at execution time" warnings to any embedded host
  that registers executors after its first run (legal under ADR-0018).

Tests: embedded host warns once; unknown/disabled flow names do not trigger
it; the warn does not seal; plus two sentinels — an explicitly-sealed host and
the AutomationServicePlugin boot gain no log line. decision-branch-routing's
harnesses now seal (they are embedded hosts asserting zero warnings about
#4414 routing), which the inversion check proves is load-bearing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWS4heBoAitLmzCLhcYdbK
@vercel

vercel Bot commented Aug 5, 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 5, 2026 8:10pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 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.

Copy link
Copy Markdown
Contributor Author

一个审阅者大概会问的边界,先说清楚:告警只挂 execute(),没有挂 resume()

resume()resumeInternal(),不经过 execute(),所以「进程重启 + 持久化 suspended-run store,新进程第一件事就是 resume(runId)」这条路径不会立刻告警 —— 要等该 host 下一次真正 execute() 才说。这是覆盖面更窄,不是判断有误:验收要的就是「首次执行」,而 resume 的前提是某个进程里曾经执行过并挂起。刻意不铺开,一是本单范围就是 execute() 首跑,二是每多一个入口就多一处需要证明「插件路径零新增日志」的哨兵。若维护者认为重启即 resume 的嵌入式部署值得覆盖,那是一个独立的小扩面单,不必阻塞本 PR。


Generated by Claude Code

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/m tests tooling

Projects

None yet

2 participants