Skip to content

[approvals] 存量僵尸请求无人认领:已终态但 run 悬空的请求落在 releaseDeadRunRequests 的盲区里 #4469

Closed

Description

@os-zhuang

#4420 的后续。那个 PR(#4460)修的是不再产生新的僵尸;已经卡住的请求仍然没有任何机制发现或释放。

问题

#4420 的故障形态是:请求行已翻成 approved(或 rejected),但它的 flow_run_id 指向的 run 已经不存在——决策落库了,流程永远不动。凡是在装有 17.0.0-rc.1 且撞上装配漏洞的部署上、于重启前后跨越的审批,都可能留下这种行。

现有的死信清扫 ApprovalService.releaseDeadRunRequests()(packages/plugins/plugin-approvals/src/approval-service.ts)扫不到它们,原因很直接:

  • 它只扫 status: 'pending' 的请求;
  • 而僵尸的状态恰恰已经不是 pending——它是被那次"成功"的决策翻成终态的。

也就是说,把请求推入僵尸态的那一步,同时把它推出了唯一一个会去检查它的清扫器的视野。这也是这类故障能长期无声的原因之一:没有任何一层在看它。

另外 releaseDeadRunRequests 判定"死"的依据是 getRun(runId) 返回终态状态,而 getRun 读的是执行日志环形缓冲——重启后对一个活着的挂起 run 同样返回 null。它把 null 当作"还活着"来处理(保守,正确),但这也意味着它本来就没有能力回答"这个 run 是不是真的没了"。

现在有了可用的原语

#4460 引入了 AutomationEngine.hasSuspendedRun(runId):直接问挂起存储,而不是问执行日志;库读不到时抛错而不是答 false(避免把一次抖动误判成死 run)。它已经通过可选的 ApprovalResumeSurface.hasSuspendedRun 暴露给审批侧,可以直接复用。

期望

一个管理端清扫/巡检,能找出终态请求中 run 已不可恢复的行:

  • 扫描条件大致是:flow_run_id 非空 且 请求已终态 且 hasSuspendedRun(runId) === false 且 该 run 也没有终态 run 记录(sys_automation_run 里 run_ 前缀的历史行)——即"既不在挂起,也没跑完",这才是真悬空;
  • 对 hasSuspendedRun 抛错的情况必须跳过而非判死(存储不可读 ≠ run 不存在);
  • 输出面向人工:哪些记录卡在哪一级、镜像字段停在什么状态。不建议自动改写状态——决策是真实发生过的,自动回滚会让审计与事实不符;先做成可见的报表/告警,由管理员决定是重跑后续动作还是重新发起。

参考

Activity

  1. self-assigned this
    on Aug 1, 2026
  2. os-zhuang commented on Aug 1, 2026

    @os-zhuang
    ContributorAuthor

    已由 #4502 修复(已合入 main,commit 5a84d4148)。把说明留在这里,因为这是下一个读者最可能来的地方。

    根因 —— 为什么它能长期无声

    故障形态就是本 issue 描述的:请求行已翻成 approved / rejected / returned,而它的 flow_run_id 指向的 run 已不存在 —— 决策落库了,流程永远不动。

    releaseDeadRunRequests 扫不到它们,原因值得原样写下来:它只扫 status: 'pending',而把请求推入僵尸态的那一步,正是把它推出 pending 的那一步。弄坏它的动作同时把它移出了唯一一个会去看它的清扫器的视野。

    而且即便它去看了也答不上来:它的存活性判据是 getRun,读的是执行日志环形缓冲 —— 重启后对一个活着的挂起 run 同样返回 null。它把 null 当作「还活着」(保守,且对它自己要做的事是正确的)—— 这恰恰意味着它没有能力说出「这个 run 是真的没了」。

    改动

    新增 ApprovalService.inspectStrandedRequests()(packages/plugins/plugin-approvals/src/approval-service.ts),同时用两个判据,两个都不通过才报告:

    • hasSuspendedRun(runId) === false —— 直接问挂起存储,没有活的暂停。该方法在库读不到时抛错,这种情况被跳过并计入 undetermined,绝不判死:存储不可读 ≠ run 不存在。
    • getRun(runId) == null —— 也没有终态历史行(sys_automation_run 里 run_ 前缀的行)。跑完的 run 不算悬空;既不在挂起、也没跑完的才算。

    只报告,绝不改写 —— 按 issue 的建议做的。决策是真实发生过的,自动回滚会让审计与事实不符。报告面向人工:哪些请求卡在哪一级(requestId / status / runId / nodeId / flowName / objectName / recordId),以及业务记录上镜像的状态字段现在读作什么(通常就是用户正盯着的那个过期值)。新增导出 StrandedApprovalRequest 为报告行的类型。

    它挂在既有的 SLA/死信清扫时钟上(approvals-plugin.ts),所以运维不必知道要去找,日志里就会浮出来。

    recalled 刻意排除:撤回本就是有意放弃它的 run(recall 明确容忍一个无法 resume 的 run),把它们报出来只会让真正的发现被淹没。

    验证证据

    packages/plugins/plugin-approvals/src/stranded-request-inspection.test.ts,13 例全过。关键几条:

    • 一例直接把盲区本身钉成断言 —— 对一行 approved 的僵尸,releaseDeadRunRequests() 返回 { scanned: 0, released: 0 }(它的扫描集是空的)。
    • 找到僵尸并报出全部定位信息,以及过期的镜像状态(决策说 approved,记录仍读 pending)。
    • 两条阴性:run 仍挂起 → 不报;run 已终态 → 不报。
    • 两条「不可知」:hasSuspendedRun 抛错、getRun 抛错 —— 都跳过且 undetermined === 1,所以「0 stranded」永远不会被误读成「一切正常」。
    • 一例快照整个假数据库前后 JSON 相等,钉死绝不改写。
    • 两条降级:引擎没有 hasSuspendedRun、或根本没接 automation → 不给结论(没有判据就不下判决)。

    撤销验证:把存活性判据取反(if (!suspended) continue)后该套件失败。

    文档(content/docs/automation/approvals.mdx 的死信 callout 补上这一类看不见的终态请求)与 changeset 因合并队列锁分支而拆在 #4517。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions