Skip to content

fix(service-automation): refuse a suspension the node type declares it cannot produce (#6667) - #6746

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-6667-supportspause-runtime-check
Aug 8, 2026
Merged

fix(service-automation): refuse a suspension the node type declares it cannot produce (#6667)#6746
os-project-manager merged 1 commit into
mainfrom
claude/issue-6667-supportspause-runtime-check

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Closes#6667

背景

ActionDescriptor.supportsPause 一直只在编写期被读:designer palette、warnIfResumeAuthorityUndeclared 注册警告、以及 check-resume-authority-declared.mjs CI gate。运行期的暂停只有一个来源 —— executor 的 execute() 返回了 suspend: true —— 所以一个 executor 完全可以在 descriptor 声明 supportsPause: false 的情况下把 run 挂起,而上面三个座位按构造全部沉默:它们都以 supportsPause: true 为触发条件。

本 PR 只做 #6667 的第一件事(运行期一致性检查)。第二件事(isAsync 处置)按派发要求只测量、不实现,测量结果见下,卡片由 PM 另开。

判决:拒绝(refuse),不是"放行 + 记 error 日志"

这是本 PR 需要显式论证的设计决定,按 #4632 的纪律以后果而非形容词来判:

拒绝走的是 #3863 的 guard 类(refuseNode),因此 fault不会路由它 —— 声明写错不是"世界不配合",重跑一次永远不会好;若可路由,一条边就能把这个检查关掉而 run 仍报成功。

refuseUndeclaredSuspension 这里故意不打日志:按 AGENTS.md "Degradation log levels" 的第三种合法答案 —— 失败已经交回调用方,就不是 degradation;run 自己的 failed 历史行已经带着完整消息。再叠一条 logger.error 会让每一次执行错误声明的节点都产出一条 error,正是把 error 读废的做法。

边界位置:一处判定,无旁路

判定放在 AutomationEngine.executeNode 中 executor 返回结果之后、成功簿记之前(packages/services/service-automation/src/engine.ts)。

  • 这是引擎唯一会采信 executor.execute() 结果的调用点;ADR-0018 alias 路径是把目标的结果 return 到这里,自己不挂起,所以不会二次判定。
  • resume() 的续跑同样经由 executeNode 重新进入,region body(runRegion)也是 —— 三条路径共用同一处判定。
  • 放在成功簿记之前而不是紧挨着 throw new FlowSuspendSignal:这样出错节点在 run 日志里记录为一条 failure step(而不是"success step + 莫名其妙失败的 run"),$error 像任何其它 refusal 一样被写入,并继承 Node-level error branches: let a flow handle a failed node instead of aborting the run #3863 的不可路由性。

声明的解析走 resolveCanonicalDescriptor() —— 本 PR 把 resolveDeclaredResumeAuthority 里的 alias hop 抽出来,两个能力 gate 共用一次遍历。alias 的 descriptor 是 registerNodeAlias 合成的,不复制 canonical 的能力位,直接读它会把老类型名下的合法暂停全部误拒。

不管的两种形状(各有 pin)

仓内错误声明者:0(已测量)

以 AST 扫描全树 registerNodeExecutor 字面量,交叉核对 suspend: true 与 descriptor 声明:

出货代码测试 fixture
声明 supportsPause: true610
声明 false / 省略00
完全无 descriptor03(其中 bare_pause 就是故意的)

6 个出货 executor 为 screen / wait / map / subflow(builtin)+ approval / approval_revise(plugin-approvals),全部声明 supportsPause: true没有任何内置节点因本 PR 改变行为;受影响的只有当前处于错误声明状态的第三方 executor,而它们的暂停今天本来就是不可恢复的。

测试与反向验证

新增 supports-pause-runtime-enforcement.test.ts(9 例),并在 guard-refusal-inventory.test.ts(#3863 的 guard 清单)补一行分类 pin。

反向验证做了两个方向,预测在跑之前写下:

isAsync 的新鲜测量(只报告,本 PR 不实现)

派发要求在接手时重新测,而不是沿用 #5703 帖子上的读数。2026-08-08 于 origin/main @ cfeb9a0(objectstack)与 origin/main @ a4f837c(objectui)重测:

  • objectstack:automation/ActionDescriptor:isAsync 的读者 0。全部命中为一处 schema 声明、三份生成基线(authorable-surface*/authorable-defaults)、5 处出货 descriptor 的写入(screen / map / wait / approval / approval_revise)、若干测试 fixture 与形状断言、以及 docs/ADR 散文。没有任何一处 .isAsync读取
  • objectui:isAsyncsupportsPause 命中数均为 0(既无读者也无写者)。
  • cloud:该环境没有这个仓库(容器内只有 objectstackobjectui);作为交叉核对,GitHub 全组织代码搜索 isAsync org:objectstack-ai 返回 19 条,全部落在 objectstack
  • 一处同名但不同键需要在开卡时排除:packages/spec/src/data/hook.zod.ts 里的 isasync 是 data hook 的 tombstone 别名(映射到 async),与 ActionDescriptor.isAsync 无关。

结论:读数仍是"零读者",指向 ADR-0049 enforce-or-remove 的退役路线。退役会动 packages/spec,按分诊注记归 spec 座位,故本 PR 不含任何 isAsync 改动。

遗留:一处 packages/spec 文档注释需要跟进(不在本 PR 内)

packages/spec/src/automation/node-executor.zod.tssupportsPause 的文档注释仍写着 "A declaration, not an enforced fact (#5703). No execution path reads it",本 PR 之后这句话已经不成立。派发明确要求本 PR 不动 packages/spec(契约面只有一个 owner),所以留给 spec 座位跟进 —— 已在回报中标出。

验证

  • pnpm --filter @objectstack/service-automation test71 files / 868 tests passed
  • pnpm --filter @objectstack/plugin-approvals test19 files / 446 tests passed
  • tsc --noEmit -p packages/services/service-automation → 3 errors,全部是既有的 nested-region-parity.test.ts TS2341 私有成员读取(ledger 记 5,棘轮只在超过时失败);新文件已确认在 tsc program 内且贡献 0 个错误
  • eslint(engine.ts + 两个测试 + gate 脚本)→ clean
  • gates:check:resume-authority-declared(含 --self-test)、check:nul-bytescheck:engine-double-contractcheck:error-code-casingcheck:durability-log-levelcheck:startup-registry-verdictcheck:route-envelopecheck:empty-changeset 全绿

🤖 Generated with Claude Code

https://claude.ai/code/session_01USNUyHEr7uaU6MoEWXitei


Generated by Claude Code

…t cannot produce (#6667)
`ActionDescriptor.supportsPause` was read only at authoring time — the designer
palette, `warnIfResumeAuthorityUndeclared`, and `check:resume-authority-declared`.
A run pauses for one reason only, `execute()` returning `suspend: true`, so an
executor could suspend while its descriptor declared `supportsPause: false` and
all three seams stayed silent by construction: every one of them keys on
`supportsPause: true`.
`AutomationEngine.executeNode` now judges the executor's suspend result against
the (alias-resolved) declaration at the single seam every suspension passes
through, and refuses the mismatch as a guard-class node failure — un-routable by
a `fault` edge, since re-running cannot fix a wrong declaration.
Refusing rather than pausing-and-logging is decided on consequence: a type that
declares no pause declares no `resumeAuthority` either, and since #5561 an
unclaimed pause is fail-closed, so the honoured pause would write a durable
continuation the generic resume route then refuses — at resume time, naming
`resumeAuthority` rather than the `supportsPause` that caused it.
Two shapes stay untouched: `supportsPause: true` on a type that never suspends
(a capability, not an obligation), and an executor that publishes no descriptor
at all (no declaration to enforce; #5561's resume gate already fail-closes it).
Measured on this branch: all six shipped pausing executors declare
`supportsPause: true`, so no built-in changes behaviour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USNUyHEr7uaU6MoEWXitei
@vercel

vercelBot commented Aug 8, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 8, 2026 2:00pm

Request Review

@github-actions

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

automation: enforce ActionDescriptor.supportsPause at runtime (executor suspend vs declaration), and decide isAsync disposition

2 participants

@os-project-manager@claude