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

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 5, 2026 3:36pm

Request Review

@os-zhuangos-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

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

2 participants

@os-zhuang@claude