发现于 #5512 的修复过程(PR #5527),与该 PR 相邻但不同因,按 Prime Directive #10 单开。未在 PR #5527 中修改。
现象(按代码判定,非线上实测)
packages/services/service-automation/src/builtin/wait-node.ts 里 timer wait 排下的一次性唤醒 job,其回调是:
try { await engine.resume(runId) } finally { await job.cancel?.(jobName) }
finally 无条件取消 —— 但 engine.resume() 是返回结果对象而不是抛错的,所以「这一枪打空了」和「这一枪打中了」在这里无法区分。打空的两种情况(origin/main 的 engine.ts,resumeInternal 内):
第二种无害(另一路 resume 会消费掉暂停,PR #5527 之后它也会顺带取消 job)。第一种是个洞:暂停没被消费(run 还挂在 wait 节点上),但它唯一的唤醒 job 已经被取消了。此后没有任何东西会唤醒它,直到下一次进程启动 —— rearmSuspendedWaitTimers 会把它当 overdue 立即 resume。也就是说:store 在唤醒那一刻抖一下 + 之后不重启 = 这个 run 永远停在 wait 节点。
窗口很窄(要恰好在到点那一刻 store 不可达),也能靠重启自愈,所以不急;但它属于 #4632 明确要防的那一类「持久化承诺没兑现」——只不过 #4632 覆盖的是 re-arm 路径的降级,这条在 timer 回调里,当时没看。
期望
finally 里的自我取消应当只在「这一枪确实打中了(暂停被消费了)」或「这个 job 无论如何不该再响」时执行,而不是把 store 故障也当成打中。可能的方向(未决,交 PM/维护者裁断):
- 按
resume() 的返回码分流:STORE_UNAVAILABLE 时不取消(让 job 服务的既有重试/下次到点再试),其余照旧取消;
- 或者让这条路径显式记一条 error(现在是静默的:结果被回调丢弃,没有任何日志),这样至少可观测。
注意 PR #5527 引入的 onSuspensionReleased 拆除不覆盖这条:它只在暂停真的被消费时才触发,正是「打中了」的那一半;finally 保留下来的职责恰恰是「这一次性 job 不该再响」,两者的语义分歧就是这个 issue。
佐证
wait-node.ts(origin/main)L87-L92:finally + One-shot: drop the job so it never re-fires。
engine.ts(origin/main)L2736 / L2757:两个「resume 没消费掉暂停」的返回码。
rearmSuspendedWaitTimers:overdue 分支会在下次启动时立即 resume,这就是唯一的自愈路径。
发现于 #5512 的修复过程(PR #5527),与该 PR 相邻但不同因,按 Prime Directive #10 单开。未在 PR #5527 中修改。
现象(按代码判定,非线上实测)
packages/services/service-automation/src/builtin/wait-node.ts里 timer wait 排下的一次性唤醒 job,其回调是:finally无条件取消 —— 但engine.resume()是返回结果对象而不是抛错的,所以「这一枪打空了」和「这一枪打中了」在这里无法区分。打空的两种情况(origin/main的engine.ts,resumeInternal内):STORE_UNAVAILABLE(L2757 一带):持久 suspended-run store 读不到 —— 按 [automation/approvals] 进程重启后审批决策静默失效:挂起 flow run 仍只存内存(#1518 标记 COMPLETED 但 17.0.0-rc.1 未生效),approve 落库却永不推进且零报错 #4420 的设计这不能被当成「没有这个 run」,暂停仍然存在、行还在;RESUME_IN_PROGRESS(L2736):另一个 resume 正在进行中。第二种无害(另一路 resume 会消费掉暂停,PR #5527 之后它也会顺带取消 job)。第一种是个洞:暂停没被消费(run 还挂在 wait 节点上),但它唯一的唤醒 job 已经被取消了。此后没有任何东西会唤醒它,直到下一次进程启动 ——
rearmSuspendedWaitTimers会把它当 overdue 立即 resume。也就是说:store 在唤醒那一刻抖一下 + 之后不重启 = 这个 run 永远停在 wait 节点。窗口很窄(要恰好在到点那一刻 store 不可达),也能靠重启自愈,所以不急;但它属于 #4632 明确要防的那一类「持久化承诺没兑现」——只不过 #4632 覆盖的是 re-arm 路径的降级,这条在 timer 回调里,当时没看。
期望
finally里的自我取消应当只在「这一枪确实打中了(暂停被消费了)」或「这个 job 无论如何不该再响」时执行,而不是把 store 故障也当成打中。可能的方向(未决,交 PM/维护者裁断):resume()的返回码分流:STORE_UNAVAILABLE时不取消(让 job 服务的既有重试/下次到点再试),其余照旧取消;注意 PR #5527 引入的
onSuspensionReleased拆除不覆盖这条:它只在暂停真的被消费时才触发,正是「打中了」的那一半;finally保留下来的职责恰恰是「这一次性 job 不该再响」,两者的语义分歧就是这个 issue。佐证
wait-node.ts(origin/main)L87-L92:finally+One-shot: drop the job so it never re-fires。engine.ts(origin/main)L2736 / L2757:两个「resume 没消费掉暂停」的返回码。rearmSuspendedWaitTimers:overdue 分支会在下次启动时立即 resume,这就是唯一的自愈路径。