fix(automation,approvals): 审批决策不能在流程原地不动的情况下"成功" (#4420) - #4460
Merged
Conversation
… while its flow stays parked (#4420) A flow paused at an `approval` node, a deploy, then an approver clicking Approve: the request row flipped to `approved`, the UI toasted success — and the flow never moved. No next-stage request, no error, the record's mirrored status frozen mid-workflow. Approval flows pause for days by design, so a restart mid-flight is the normal case, not the edge one. Durable suspended runs (#1518) had shipped and were not the missing piece. Two other things were. The wiring could enable a store over a table nobody had created. Object registration and store activation resolve different services in different phases — `manifest` at init(), `objectql` at start() — and the plugin declared no ordering. Composed ahead of ObjectQL, init() found no `manifest`, warned, and continued; start() attached the DB-backed store anyway. Every suspend then failed with `no such table: sys_automation_run` into a log line nobody read. Now: `optionalDependencies: ['com.objectstack.engine.objectql']` (order-if- present per ADR-0116 — an engine-less kernel must still boot), a missed registration retried at start() while it still precedes schema sync, no store attached when registration did not happen (at error level), a boot-time table probe, and a failed durable write logged at error rather than warn. A reported resume failure read as success. `AutomationEngine.resume()` answers a lost run by RETURNING `{ success: false }`, never by throwing; approvals discarded that value and counted only throws as failure. Resume failures are now classified — RUN_NOT_FOUND, STORE_UNAVAILABLE, RESUME_IN_PROGRESS — so a run gone for good is distinguishable from a store merely unreachable, and the raw resume route maps them to 404 / 503 / 409. Approvals acts on them. The new `hasSuspendedRun(runId)` — which reads the suspension store, unlike `getRun()`, and throws rather than answering false when the store is unreadable — pre-flights decide / sendBack / resubmit BEFORE their first write, so the zombie half-state is never created rather than merely reported: RESUME_TARGET_LOST (409), request left actionable. A resume that fails after the decision is durable throws RESUME_FAILED (500) naming the stranded run. A concurrent duplicate stays benign via the new `resumeError` field; recall and revise-window cancellation stay non-fatal but log at error. Compositions with no automation engine attached are unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EPRaZTNbACfvGKytYESX2h
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 5 package(s): 115 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…es (#4420) `check:docs` compares `content/docs/references/` against what `gen:schema && gen:docs` produces. The five codes this branch registers in the ADR-0112 ledger — RUN_NOT_FOUND, STORE_UNAVAILABLE, RESUME_IN_PROGRESS, RESUME_TARGET_LOST, RESUME_FAILED — feed `ErrorCode`, which the generator inlines into every response-envelope table. Hence eleven files, all of it the same union widening: the ledger page gains the five entries and every page that renders an ApiError picks them up. Generated, not hand-edited: pnpm --filter @objectstack/spec gen:schema && pnpm --filter @objectstack/spec gen:docs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EPRaZTNbACfvGKytYESX2h
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.
修复 #4420。
现象
流程停在
approval节点 → 发版重启 → 审批人点「批准」:请求行翻成approved、UI 提示成功,但流程一步没动。下一级审批请求不创建、记录上镜像的状态冻结在半路、全链路零报错。审批流天然一挂就是几天,中间隔一次发版是常态而非边缘情况——也就是说每次发版都可能把在途审批悄悄变成僵尸,而审批人侧毫无感知。定位:#1518 并不是缺失的那一环
issue 推断 #1518(挂起 run 持久化)没落地。实际查下来它已经落地并接好了:
suspended-run-store.ts、sys-automation-run.object.ts、引擎的冷启动 rehydrate、幂等 resume 都在,suspendedRuns只是热缓存而不是唯一存储。真正造成该现象的是另外两个缺陷,两者叠加正好复现报告的全部症状。一、装配漏洞:store 可以挂在一张根本没建的表上
对象注册在
init()里走manifest,store 激活在start()里只看objectql——两个不同的服务、两个不同的生命周期阶段,而插件没有声明任何顺序。当它排在 ObjectQL 之前时:init()拿不到manifest,warn 一句继续走;start()照样把 DB store 装上。此后每次挂起都以no such table: sys_automation_run失败在一条没人看的 warn 里,run 静默退回内存,下次重启全丢。这正是 issue 里「dist 有sys_automation_run标识符、运行库里却没这张表」的来源。修复:
optionalDependencies: ['com.objectstack.engine.objectql'](ADR-0116 的 order-if-present)。不用requiresServices——assertInitServiceRequirements是无条件抛错,会硬失败所有不带 objectql 的组合(packages/verify/src/harness.ts的 memory 模式、各 LiteKernel 单测、connector 插件)。这个插件没有引擎时确实能跑(纯内存挂起),所以只能是软边。no such table在启动时暴露一次,而不是运行期每次挂起各失败一次。二、resume 失败在每一层都被读成成功
AutomationEngine.resume()对丢失的 run 是返回{ success: false }而非抛错;ApprovalService.serviceResume()把返回值丢掉;decide()只把抛错算失败。于是"run 已经没了"这件事一路返回resumed: true+ HTTP 200。而死信清扫releaseDeadRunRequests只扫pending的请求,已翻成approved的僵尸对它完全不可见——没有任何一层看得见它。修复:
RUN_NOT_FOUND/STORE_UNAVAILABLE/RESUME_IN_PROGRESS(与既有PERMISSION_DENIED/INVALID_SIGNAL并列)。"彻底没了"和"库暂时读不到"必须可区分——两者补救方式相反,一次数据库抖动被读成死 run 就会永久废掉一个活着的 run。raw resume 路由相应映射 404 / 503 / 409。AutomationEngine.hasSuspendedRun(runId):读挂起存储(getRun()做不到——它读的是执行日志,重启后对活着的 run 也返回 null),库读不到时抛错而不是答false。decide/sendBack/resubmit写第一条记录之前预检。于是"已审批 + run 悬空"的半状态不是被报告出来,而是压根不会被创建:抛RESUME_TARGET_LOST(409),请求仍是pending、仍可操作,审计里也不会留下一条没有生效的批准。RESUME_FAILED(500) 并点名那个悬空的 run,而不是报成功。resumeError字段返回。recall、修订窗口内的cancelRun)保持非致命——用户本就在放弃这个请求,本地终结和记录解锁必须成功——但改为 error 日志带原因,不再吞掉。hasSuspendedRun的情况,行为完全不变。行为变更(供 API 消费方注意)
decide/sendBack/resubmit现在会在 run 不可恢复时抛错(RESUME_TARGET_LOST→ 409,RESUME_FAILED→ 500),而不再返回resumed: true。这正是 issue 的第 2 条期望(「approve 接口应失败或至少告警,不能落一半状态静默成功」)。Console 会显示 409 的说明文案。测试
新增
packages/plugins/plugin-approvals/src/approval-restart-resume.test.ts——其中「进程 A 挂起 → 进程 B 审批」既是 #4420 的复现,也是 #1518 第一次真正穿过审批链路的验收(此前只有引擎侧自测):approved;RESUME_TARGET_LOST,请求仍为 pending、无 approve 审计行、下游节点未执行;RESUME_FAILED并点名 run;重复 resume ⇒ 良性,resumeError有值;新增
packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts覆盖装配的五种组合(正常 / 漏洞 / manifest 晚到 / 探测失败 / memory 模式),外加引擎侧的失败码与hasSuspendedRun语义测试。本地:service-automation 581、plugin-approvals 341、spec 7228、rest 539、runtime 1023 全绿,
pnpm typecheck122/122、pnpm lint、check:error-code-casing、check:service-providers均通过;依赖包(triggers / connectors / verify / cli / objectql)也已跑过。不在本次范围
已有部署里存量的僵尸请求(已
approved、run 悬空)本次不修复——releaseDeadRunRequests只扫pending,需要单独一个管理端清扫。本 PR 只保证不再产生新的。Generated by Claude Code