发现于 #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` 内): - `STORE_UNAVAILABLE`(L2757 一带):持久 suspended-run store 读不到 —— 按 #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 服务的既有重试/下次到点再试),其余照旧取消; - 或者让这条路径显式记一条 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,这就是唯一的自愈路径。
Activity
os-zhuang commentedon Aug 5, 2026
分诊(services 车道 PM,
session_01BWS4heBoAitLmzCLhcYdbK):晋级pm:queue。判定:具体耐久性缺陷,窗口窄但形状真实(store 在到点瞬间不可达 → 唯一唤醒 job 被
finally无条件取消 → run 挂死至下次重启的 overdue re-arm),属 #4632「持久化承诺未兑现」一族;修法不动公开契约,车道内可决,不升级。派发时的已知约束(step 3 记录,防后续 sweep 误读):
wait-node.ts),按同文件串行规则Blocked-by:它落地后才可派;且 fix(automation): release the wait node timer job when the run leaves the node (#5512) #5527 引入的onSuspensionReleased只覆盖「暂停被消费」的那一半,本单修的是finally保留下来的另一半职责 —— 两者语义分界 issue 正文已写清,修复时不要合并两个机制。resume()返回码分流 ——STORE_UNAVAILABLE时不取消 + 记一条 error(现在是完全静默),其余照旧取消。派发前 dev 须先核实IJobService对 once-job 回调的重试语义:若 once-job 打完一枪后不会再响,则「不取消」单独并不构成自愈,响亮 error + 保留active行(供运维看见)才是诚实形状 —— 以实测为准,不猜。Blocked-by: #5527
Generated by Claude Code
os-zhuang commentedon Aug 5, 2026
认领:PM 循环第 3 轮(services 车道)。Blocked-by 已解除:PR #5527(#5512)已 MERGED。
会话:
session_01BWS4heBoAitLmzCLhcYdbK分支:
claude/issue-5529-wait-timer-store-unavailableWorktree:
objectstack-issue-5529域:
domain:services文件面:
packages/services/service-automation/src/builtin/wait-node.ts(timer 回调 finally)、同包测试、.changeset/*.md修法方向沿分诊评论:按
resume()返回码分流 + 响亮 error;dev 先实测IJobService对 once-job 回调的重试语义再定「不取消」是否构成自愈。同包 #5048/#4792 继续串行排队。Generated by Claude Code
os-zhuang commentedon Aug 5, 2026
复核:ACCEPT → PR #5549(CI 绿后转 ready 入合并队列)。
已核项:
STORE_UNAVAILABLE(resumeInternal仅在缓存未命中时读持久 store);真正可达的现场是rearmSuspendedWaitTimers重排的那一枪 —— issue 只把它当自愈路径。两处经共用makeWaitTimerJobHandler一并修掉(与 [17.0-rc2验收] wait 定时暂停被外部 resume 短路后,一次性唤醒 job 仍保持 armed —— 次日对已完成 run 发起幽灵 resume,sys_job 里留下误导性的「待唤醒」行 #5512 收敛waitTimerJobName同理);setTimeout,+1000ms 仍 1 次)→「不取消」不构成自愈 → 落到分诊预设的第二分支(不取消 + error),且实测给出了「不取消」保住的两样东西(active: true行是真话;trigger()可不重启重打)—— error 日志把「非自动重试」与两条补救都写明;engine.ts未动(消费的是 spec 已宣告的公开AutomationResult.code字段);onSuspensionReleased与finally两机制未合并,语义分界保持;load抛错),排班路径用例注入返回码并在注释里写明为何不伪造缓存未命中 —— 诚实;check:durability-log-level等门禁 OK;生成物重锚已还原;DbJobAdapter.wrap把内部降级记成 success 的审计面)已按 finding 归档,不阻塞本单。排程:本单落地后解锁同包 #3823(裁决后优先单)→ #5048 → #4792 串行链。
Generated by Claude Code
fix(automation): keep a wait timer's one-shot armed when the shot nev…