Skip to content

fix(automation): release the wait node timer job when the run leaves the node (#5512) - #5527

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5512-wait-resume-job-cancel
Aug 5, 2026
Merged

fix(automation): release the wait node timer job when the run leaves the node (#5512)#5527
os-zhuang merged 1 commit into
mainfrom
claude/issue-5512-wait-resume-job-cancel

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5512

复核结论:issue 的前提成立

origin/main(基线 308c709)逐条核对:

  • packages/services/service-automation/src/builtin/wait-node.tsjob.cancel?.(jobName) 确实只出现在两处 finally —— timer 回调自身,以及 rearmSuspendedWaitTimers 里同款回调;
  • engine.ts 全文 IJobService 出现 0 次,resume() / resumeInternal() 对 job 服务完全无感知;
  • suspend 时写入的 correlation 就是 job 名(flow-wait: 前缀那串),是现成的钩子;
  • DbJobAdapter.cancel(name) 会把 sys_job 行置 active: false(setActive(name, false)),所以「取消 job」正是 issue 期望的那个动作;对不存在的 job 名调用 cancel 是安全的 no-op(内层适配器 jobs.delete,setActive 找不到行直接 return)。

所以外部 resume 之后残留一行 active: true 的一次性唤醒 job,与次日的幽灵 resume,都是真实缺口。

修法:让「暂停结束」这件事有人通知节点

问题的形状不是「resume 忘了取消 job」,而是暂停只有单向契约:节点能 suspend: true,但没有任何回路告诉它暂停结束了。所以补的是缺失的那一半,而不是在 resume 路径上硬编码一句 job 取消(那会让 engine 认识 job 服务,并且下一个「入场时挂了个外部东西」的节点还得再补一次)。

engine 侧:NodeExecutor 新增可选 onSuspensionReleased(release),由 forgetSuspendedRun 派发 —— 那是所有「消费掉一个暂停」的唯一收口(resume 消费、failSuspendedRun 终态失败、cancelRun 取消),因此实现了这个钩子的节点在三条路径上都会被通知,不需要知道是哪条。派发按暂停节点自己的注册类型路由(优先 suspend 时记录的 nodeType,回落到活的 flow 定义 —— 与 #3801 resume gate 同一套解析,顺手抽成 resolveSuspendedNodeType 供两处共用);ADR-0018 的过时别名会把这个钩子委派给 canonical 执行器,理由和 resolveResumeAuthority 走别名一样:别名不该悄悄吃掉一个能力。

回调在暂停已从内存缓存与持久 store 移除之后运行,异常被 engine 捕获并记一条 warn(带上 correlation,也就是运维手动取消时要用的 job 名):拆除是尽力而为的可观测性工作,不能拖慢、更不能弄坏 continuation。

wait 节点侧:实现该钩子,取消「名字是它自己铸出来的那一个」一次性 job。判定用重建 waitTimerJobName(runId, nodeId)全等比较,而不是前缀匹配一个可能不属于它的字符串 —— 于是降级 timer(correlation 是 timer: 那种)和 signal wait(correlation 是作者写的信号名)都不会误调 cancel。job 名的三个使用点(排班、冷启动 re-arm、拆除)收敛到一个函数,不会再各写一遍。

timer 回调自身的 finally 取消保留:两者回答的是不同问题 —— 钩子是「run 离开了这个节点」,finally 是「这个一次性 job 已经打完唯一那一枪」(包括那些没消费掉暂停的枪,例如 store 不可达、或另一个 resume 正在进行中)。cancel 幂等,重复调用无副作用。

测试

新增 packages/services/service-automation/src/suspension-release.test.ts(8 个,钉 engine 契约:resume/cancel/failed 三种 reason、只通知暂停节点那一个执行器、冷路径(store 复水后换引擎)、抛异常不影响 continuation 且 warn 里带 correlation、别名委派),以及 wait-node.test.ts 里 6 个(issue 复现向:外部 resume / cancelRun / 冷启动 re-arm 后再外部 resume 都会取消;signal wait 与「无可解析时长」的 timer 一个都不取消;timer 自己到点仍然取消且只取消这一个名字)。

failed 那条走的是真实的 subflow 祖先失败路径,包了一层记录用的装饰器复用真 subflow 执行器 —— 它闭包持有活引擎,手写替身等于在测替身。

反向验证(方向事先预判为「红」,结果一致):把 forgetSuspendedRun 里的通知调用摘掉后,10 条转红(3 条 wait-node 路径 + 7 条 engine 契约),而三条负面对照(signal wait 不取消、降级 timer 不取消、timer 自己到点仍取消)与「未实现钩子的暂停节点照常工作」保持绿 —— 说明新增断言钉的是这次真正改掉的行为,不是「因为什么都没产生所以通过」。

pnpm --filter @objectstack/service-automation test
  Test Files  57 passed (57)
       Tests  689 passed (689)

# 消费半径:依赖 service-automation、且测试会跑 suspend/resume 的包
pnpm --filter @objectstack/plugin-approvals --filter @objectstack/trigger-schedule --filter @objectstack/trigger-record-change test
  plugin-approvals       19 files / 441 tests passed
  trigger-schedule        3 files /  42 tests passed
  trigger-record-change   5 files /  55 tests passed

tsc --noEmit -p packages/services/service-automation/tsconfig.json  →  5 errors,全部落在本次未改动的文件(engine.test.ts x2 / nested-region-parity.test.ts x3),改动前后同一批,新增 0 条
node scripts/check-type-check-coverage.mjs  →  OK
node scripts/check-nul-bytes.mjs           →  OK

本包无 typecheck script(在 check-type-check-coverage.mjs 的 DEBT 账本里),故直接跑 tsc --noEmit 取数。

影响面

onSuspensionReleased 是可选成员,不实现的节点(approval / screen / map / subflow / 第三方节点)行为完全不变。对已有暂停行的兼容:nodeType 缺失的旧行按活 flow 定义回落解析,与 resume gate 的做法一致。


Generated by Claude Code

…the node (#5512)

A timer `wait` arms a one-shot `flow-wait:<runId>:<nodeId>` job on entry, and
only that job's own callback ever cancelled it. Every other exit from the pause
left it armed: an early resume through the REST resume door (open for `wait` by
the #3801 gate), `cancelRun` (ADR-0044), or a terminal failure under a subflow
ancestor. The reported symptom was a `sys_job` row still `active: true` with
tomorrow's deadline a day after its run had completed, followed by a ghost
`resume` at that completed run.

`NodeExecutor` gains an optional `onSuspensionReleased(release)` — the mirror of
`suspend: true` — dispatched from `forgetSuspendedRun`, the one choke point every
consumption of a suspension already passes through, and routed to the executor of
the node that paused (recorded `nodeType` first, live flow as the fallback;
deprecated ADR-0018 aliases delegate to their canonical). The `wait` node
implements it by cancelling the one-shot whose name it recognises as its own, so
a pause that armed nothing (signal wait, timer with no parseable duration)
cancels nothing. Teardown runs after the suspension is consumed and its failures
are logged, never propagated: it must not delay or fail the continuation.

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 3:36pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 5, 2026
@os-zhuang os-zhuang changed the title fix(automation): release a wait node's timer job when the run leaves the node (#5512) fix(automation): release the wait node timer job when the run leaves the node (#5512) 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.

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