fix(automation): release the wait node timer job when the run leaves the node (#5512) - #5527
Merged
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 5, 2026 16:02
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5512
复核结论:issue 的前提成立
对
origin/main(基线 308c709)逐条核对:packages/services/service-automation/src/builtin/wait-node.ts里job.cancel?.(jobName)确实只出现在两处finally—— timer 回调自身,以及rearmSuspendedWaitTimers里同款回调;engine.ts全文IJobService出现 0 次,resume()/resumeInternal()对 job 服务完全无感知;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 自己到点仍取消)与「未实现钩子的暂停节点照常工作」保持绿 —— 说明新增断言钉的是这次真正改掉的行为,不是「因为什么都没产生所以通过」。本包无
typecheckscript(在check-type-check-coverage.mjs的 DEBT 账本里),故直接跑tsc --noEmit取数。影响面
onSuspensionReleased是可选成员,不实现的节点(approval / screen / map / subflow / 第三方节点)行为完全不变。对已有暂停行的兼容:nodeType缺失的旧行按活 flow 定义回落解析,与 resume gate 的做法一致。Generated by Claude Code