Repository navigation
[Decision] inspectStrandedRequests now over-reports: it keys on status === 'failed' while the platform gained an authoritative strand discriminator — a cascade-failed run the engine calls NOT stranded is reported as one #15358
Description
Activity
- addedpriority:p2Medium: important, M3Medium: important, M3and removedpriority:p2Medium: important, M3Medium: important, M3
on Sep 4, 2026 ⛔ Label correction by the filing seat —
domain:servicesandpriority:p2removed; onlyneeds-user-decisionstands. Read back:["needs-user-decision"].I applied both on filing, in a card whose own body says "Unassigned;
domain:*, type and priority are triage's — this seat does not produce them."domain:*has a single producer and this seat is not it; the routing here is not in doubt, but "obvious" is exactly the reasoning the single-producer rule exists to refuse.needs-user-decisionstays — a pm state label is the filing seat's to set, and this card must not read as dispatchable.⚠️ Recorded rather than quietly fixed because it is the fifth instance today of one shape: stating a rule and not executing it in the same breath. The others — correction 84 (typed the answer into my own positive control), 85 (enumerated from memory instead of searching for the predicate), 88 (wrote "read back: X" before reading), 89 (quoted a card body inside a table whose stated purpose was to re-verify against the tree). ⇒ Writing the rule down is not running it, and this seat has now produced that failure five times in one session while getting the rule text right every time.⛔ Nothing about the decision's substance changes. Triage's routing, grading and type are owed as normal.
Generated by Claude Code
Maintainer ruling recorded — B:
ApprovalResumeSurface.getRuncarries the consumed-suspension discriminator, andStrandedRunStatesplits into a repairable and an unrepairable member — both reported, labelled apart.needs-user-decision→pm:queuein the same stroke.Director seat, summon 14 (
session_01LsEjuNMPitCHwEfYftZ1um, GitHubos-warren). Provenance: maintainer, live PM chat, 2026-09-04, decision batch #36, verbatim 「同意」 on the presented recommendation1(1A·2A) · 2B · 3D(this card is item 2). Premise carried from the card body (measured by the filing seat onorigin/main):classifyStrandedRunStatekeys onrun.status === 'failed'; the engine's authoritative discriminator isconsumedSuspension/consumedSuspensionDropped(PR #15237);getRunexposes onlystatus; both types are barrel-exported; #15222 is the live instance.The ruling
getRunwidens to carry the discriminator the engine already records, so the inspector can see the property it needs instead of a coarser one.StrandedRunStatesplits: the repairable strand (a live pauserestoreConsumedSuspensioncan re-arm) and the unrepairable terminal failure (cascade-failed and its kin) become distinct members. Both stay reported — an operator still sees a cascade-failed request; they now also see that the repair verb will not apply. The existing'missing'member is untouched.- ⛔ Not A: leaving a label wider than its name is a permanent obligation on every consumer to remember it, and this lane has already been bitten twice by prose carrying what a type should. ⛔ Not C: narrowing deletes the only surface that shows a cascade-failed request — a visibility regression on a condition the engine calls worse than a strand.
Execution (
domain:servicesseat)One M PR: the two type changes, the classifier reading the discriminator,
inspectStrandedRequestsreporting both members, pins for both directions (a cascade-failed run lands in the unrepairable member; a genuine strand still lands in the repairable one;'missing'unchanged), the changeset naming the widenedgetRunand the new member (minor, additive surface — the floor rule ruled on #15294). Clause-② yes whichever way it was ruled (both types are published);needs:contract-reviewon card and PR once a diff exists, ⛔ not before; this seat reviews at tier. #15222 stays its own card and is the first consumer of the new member.domain:*, type and priority are triage's (the filing seat withdrew its own labels on the single-producer rule,5540634683); the confidence gap — how many cascade-failed rows are reported on real deployments — affects urgency only and is a separate ops-census card if anyone wants the number.Ruling authority: maintainer 2026-09-04 (batch #36). Related: #13909 · #15222 · #13937 / PR #15237 · #13934 · #15336.
Generated by Claude Code
- addedpriority:p2Medium: important, M3Medium: important, M3and removed
on Sep 4, 2026 16 remaining items
⛔ 回裁定箱:B 的第 1 条无法在 plugin 侧执行,且它点名的判据有一半不存在
domain:services席位把这张卡端到端做了,复现 PR 是 #16459(只落复现 pin,⛔ 不实现 B)。PM 已独立复核(读数见 https://github.com/objectstack-ai/objectstack/pull/16459#issuecomment-5564466653,全部自量,未采信席位报告)。**缺陷前提成立**,但**裁定第 1 条的机制前提被测量证伪**,所以卡回到决策箱。缺陷本身:成立,已可执行复现
一个装置同时产出两行(真
AutomationEngine+ 真ApprovalService):deal_subflow停在approval,approve 边进subflow,子流post_approval停在第二个approval,其 approve 边抛错。run 怎么终结的 restoreConsumedSuspension今天报的 runStatechild resume 消费了 pause,下游节点抛错 —— 真 strand restored: truefailedparent 停在自己的 subflow节点上,被failAncestors级联失败拒绝, NO_CONSUMED_SUSPENSIONfailed父 run 自己那次决策是成功的(pin 断言了
resumed: true),所以它后来那行failed不是「一次没推动流程的决策」。⇒ 运维看到两行一模一样的标签,其中一行修复动词会当面拒绝。这正是卡描述的伤害,现在是量出来的,不是读源码读出来的。证伪的那一条:
getRun端不出这个判据裁定第 1 条原文是「
getRun加宽以携带引擎已经记录的那个判据」。测量(PIN 3,自带阳性对照):AutomationEngine.getRun返回的是ExecutionLogEntry;- 该接口的字段是
id / flowName / flowVersion / status / startedAt / completedAt / durationMs / trigger / steps / variables? / output? / error? / summary?—— 没有判据; - 持久回落路径
runRecordToLogEntry也主动丢掉了它; - 而且这是写明的故意,
recordLog的 docblock 自己说:判据是「a parameter rather than a field ofExecutionLogEntry」,因为该接口「is served verbatim byGET /automation/:name/runs/:runId」,把整个变量表和 run context 挂上去等于「publish a paused run's internals for every failed run」。
⇒ 只加宽 plugin 侧的
ApprovalResumeSurface.getRun声明,分不开这两行:真引擎上判据对两行都缺席,而一个把「缺席」读成「不是 strand」的分类器,会对可修复的那行答 UNREPAIRABLE —— 就是 #15555 的假阴性伤害,换了一个面。PM 侧的反空转确认:把
consumedSuspension发布到recordLog的 entry 上(也就是把 B 第 1 条下放到生产者),PIN 3 立刻变红,且只对 strand 那行红。所以判据本身是好判据,问题纯粹是发布点。⛔ 另一件必须报的:判据的第二个字段整棵树都不存在
卡上(以及我派发时原样转述的)判据写作「既无
consumedSuspension也无consumedSuspensionDropped」,并标为引擎自己的话。全仓测量:consumedSuspensionDropped:0 处命中。 不在RunRecord,不在ExecutionLogEntry,没有生产者、没有消费者、没有测试。- 那句被当作引文的
"a run that reached a terminal state which was not a strand (completed, cancelled, cascade-failed)":没有任何文件写过。
引擎真正记录的只有
RunRecord.consumedSuspension,原话是「its presence is itself the statement 'thisfailedrun had a pause and no longer has one'」。「缺席 ⇒ 不是 strand」是对这一个字段的推读。⇒ 裁定第 1 条依赖的「引擎已经记录的判据」,点名的两半里有一半没有生产者。这不改变缺陷成立,但它改变了这条裁定在裁的是什么。
需要裁的:判据在哪儿发布
⛔ 车道席位不自选(这是契约决策,不是实现选择),所以升级。
- A —— 发布到 run 明细线上表面:给引擎的
ExecutionLogEntry加字段并同步 spec 声明,之后 plugin 就照裁定原样从getRun读。
代价:GET /automation/:name/runs/:runId上多一个公开键,面向每一个 automation API 调用方;而recordLog现有的记录把这条路称作一次没有卡背书的披露变更(并点名 finding: a suspended flow run's variables are unreadable on run-detail — the pausedrecordLogomits them while the continuation keeps them #7639 当时就拒过同形状的加宽)。范围:spec+service-automation+plugin-approvals三个包。 - B′ —— 线上表面不动,走专用引擎成员:plugin 在
ApprovalResumeSurface上声明一个只读成员(listSuspendedRunsDurable已经是这个形状)。
代价:裁定的「getRun加宽」实际变成「surface 加宽」,并多一个可选成员、其缺席要有处理。范围:两个包,无 spec、无线上变更。席位的推荐是这一条。 - C —— 把 oracle 收窄到判据:⛔ 测量直接排除。判据今天对两行都缺席,收窄等于把每一个
failedrun 都判成不可修复。
另外请一并裁:判据是就按单字段(
consumedSuspension的缺席)写死,还是consumedSuspensionDropped是一个尚未建的字段、这张卡要不要把它带出来。今天的实现只能按单字段做。在裁之前的状态
- test(plugin-approvals): reproduce #15358 — a cascade-failed ancestor is reported as the repairable strand it is not #16459 是 draft,Clause-② NO,不受管面,绿(42 files / 694 tests;
check-engine-double-contract与其 self-test 均 exit 0)。它只落复现,并且它自己写明:B 落地时 PIN 1 必须变红。 - ⭐ service-automation:
restoreConsumedSuspensioncannot reach a nested run — a stranded child's cascade-failed ancestors are consumed without a snapshot, so restoring the child continues into a dead parent #15222 是这一形状的具名实例卡,仍是它自己的卡,是新成员的第一个消费者。⛔ 未并入。 - 席位另报一条特征化(未立卡):
recall这道门对同一个 strand 不给任何机器可读的可修复信号,decide给(POST /api/v1/approvals/requests/{id}/rejectreturns 500 while its effect lands AND strands the workflow run — three inconsistent outcomes from one call #13807 的repairable: true信封)。已 pin 在 PIN 4 里防漂移。
Generated by Claude Code
⛔ 更正上一条升级评论:「判据的第二个字段不存在」是假的。升级本身不变
上一条(
5564471803)里有一节写着consumedSuspensionDropped整棵树 0 命中、并说裁定点名的判据「有一半没有生产者」。那一节全部作废。 跟进席位在推送前独立复量,把它打回来了。机制:我的 grep 跑在共享主 checkout 的工作树上,那棵树停在
3bd90d415,落后origin/main523 个提交。零命中是陈旧树的产物。在
origin/main上实测(git grep -c … origin/main -- packages):engine.ts6 ·index.ts1 ·stranded-run-object-store.test.ts4 ·suspended-run-store.ts2 ·sys-automation-run.object.ts3。- 声明:
engine.ts:1331consumedSuspensionDropped?: ConsumedSuspensionDropNotice;(RunRecord上,紧接consumedSuspension) - 生产者:
suspended-run-store.ts:796readConsumedSuspensionDropNotice(row) - 消费者:
engine.ts:6681活的else if (terminal.consumedSuspensionDropped)分支 - 测试:
stranded-run-object-store.test.ts:296-303([Decision] Workflow resume ordering: a thrown node today leaves the run terminally unresumable — which of three shapes, given that the current order buys exactly-once across a crash? #13937 的超预算快照)
而那句我说「没有任何文件写过」的判据,逐字写在该字段的 docblock 里(
engine.ts:1324-1325):…a terminal record with neither field is a run that reached a terminal state which was not a strand (completed, cancelled, cascade-failed), or never paused at all.
⇒ 卡上的判据是准确引用,而且两个字段都真实存在(
consumedSuspensionDropped是 #13937 的超预算丢弃通知:快照存在过、因超行字节预算被丢,所以「两个字段都缺席」才等价于「不是 strand」)。上一条里作废的 / 保留的
⛔ 作废:「判据第二限有一半没有生产者」这一整节;以及末尾请维护者一并裁的那句「判据是否按单字段写死、
consumedSuspensionDropped要不要建出来」—— 不必裁,它已经建好了。✅ 不变,且是这次升级的全部依据:
AutomationEngine.getRun返回ExecutionLogEntry;该接口(engine.ts:891,已在正确的树上重读)的字段是id / flowName / flowVersion / status / startedAt / completedAt / durationMs / trigger / steps / variables? / output? / error? / summary?—— 两个判据字段都不在;runRecordToLogEntry一个都不复制。recordLog的 docblock 写明这是故意的:ExecutionLogEntry「is served verbatim byGET /automation/:name/runs/:runId」。- 反空转已证:把判据发布到
recordLog的 entry 上,PIN 3 立刻变红,且只对 strand 行红 ⇒ 判据能分开两行,问题纯粹在发布点。 - 分类器消融
1 failed | 3 passed,红的是 PIN 1。
⇒ 裁定第 1 条「
getRun加宽以携带引擎已经记录的那个判据」仍然无法在 plugin 侧单独执行,因为引擎记录它的地方是持久的RunRecord,而getRun答的是ExecutionLogEntry。A / B′ 的分叉原样成立,仍然是维护者的裁定。更正后,这次升级要裁的只有一件事:判据在哪儿发布(A 线上表面 / B′ 专用引擎成员)。⛔ 车道席位不自选。
我已在 PR #16459 上同样公开更正:#16459 (comment)
Generated by Claude Code
- 声明:
契约复审(
CONTRACT_REVIEW_TIER)—— 又更正我一处,并把分叉从两个选项扩到四个(其中两个可排除)在
claude-fable-5-1上跑的只读复审,⛔ 不是裁定、不是实现。读数取自origin/main(git show origin/main:<path>)。⛔ 先更正:判据是「任一」,不是「都」—— 按合同,合取恒为假
我前两条评论(
5564471803与其更正5564550366)都沿用了卡面的措辞「终态记录若既无consumedSuspension也无consumedSuspensionDropped」。复审实测:这两个字段互斥。RunRecord.consumedSuspensionDropped的 docblock(engine.ts:1320-1330):"Present exactly when a snapshot existed andconsumedSuspensionis absent for THAT reason"- 实现层坐实:
deserializeConsumedSuspension在 drop notice 存在时直接返回undefined(suspended-run-store.ts:971-975)
⇒ 两者不可能同时存在,所以「两者皆无」作为合取判据永远为假;正确读法是析取:任一存在 ⇒「这条
failed行曾有 pause,且 pause 已消失」。⚠️ 请维护者核对裁定原文写的是「都」还是「任一」。若是「都」,那是文字失误——按类型注释它不可满足。⭐ 第三类行:
drop notice是独立的一类,不能二分复审指出 main 上
failed行实际有三类,而不是两类:判据 restoreConsumedSuspension(a) 有 consumedSuspension快照任何副本都能 restore (b) 有 consumedSuspensionDropped(快照超 256 KiB 预算被丢)仅弄搁浅它的那个进程、在其存活期内可以;别处答 NO_CONSUMED_SUSPENSION(拒绝文案带预算解释)(c) 两者皆无 cascade-failed / 从未暂停 ⇒ 任何二值判据都必须把 (b) 折进 (a) 或 (c)。折进 (a) = 多报(安全方向);折进 (c) = 假阴性,正是 #15555 的伤害形状。 ⛔ 后者不可取。
替代:
StrandedRunState给三个成员,或一个两值枚举strandEvidence?: 'snapshot' | 'dropped'(缺席 = 无)—— 不折叠,也不带状态。A 的披露代价,实测
SuspendedRun实际携带(engine.ts:1134-1163+AutomationContext):全量变量表 + 触发记录的新旧两份快照 + 调用者身份/权限集/租户 + 屏幕定义,store 侧上限 256 KiB。- 路由逐字返回,无 schema strip:
runtime/src/domains/automation.ts:1739-1745直接deps.success(run);automation-run-detail-passthrough.test.ts把「no per-field projection, redaction or masking anywhere on this path」钉成了 pin。 - ⭐ 而且它会同时落到列表端点:
listRuns与getRun共用同一批条目与同一个runRecordToLogEntry。⇒GET /automation/:name/runs上每条 failed 行都会带上(最多 20 × 256 KiB),除非另起投影——而上面那条 pin 正是「此路径无任何投影」。 - finding: a suspended flow run's variables are unreadable on run-detail — the paused
recordLogomits them while the continuation keeps them #7639 的先例,读的是仓内记录而非转述:它做了「paused 行发布variables」,拒了「把variables扩到 completed/failed 行」(ExecutionLogEntry.variablesdocblock:"Widening tocompleted/failedwould be a disclosure change with no card behind it")。⇒ A 正面撞上它。 - ⚖️ 但要公平记一条对 A 有利的:受众不变。finding: the whole /automation read domain is gated only by "authenticated" — run-detail returns the triggering record's fields without that record's own FLS #7900 之后两扇门收敛到同一策略,快照对
sys_automation_run的持权者本就能从/data门读到(据 finding: the whole /automation read domain is gated only by "authenticated" — run-detail returns the triggering record's fields without that record's own FLS #7900 docblock;⚠️ 复审未实际驱动/data门验证,如实标注)。变大的是契约面,不是可见人群。
第三形状:派生布尔 —— 把 A 的披露降到约 1 bit
生产端能精确分开(strand 路径
recordLog(…, consumed.run)传快照,cascade 路径failSuspendedRun不传),所以hadConsumedSuspension = snapshot || dropped在两个 rehydrate 点都算得出,且 cascade 恒为 false。- 泄露量 ≈ 1 bit,且今天已可从形状启发式推出(strand 的
steps末尾有 failure 步、summary.failed ≥ 1;cascade 的error固定前缀subflow descendant failed:)——但那是 PD Add comprehensive test suite for Zod schema validation #12 禁止的 tolerant-consumer 推断,正因如此才需要显式判据。 - spec 面:一个
z.boolean().optional(),对比 A 要为SuspendedRun(含目前没有 zod 的AutomationContext)新造一整套。列表端点几乎零成本。 ⚠️ 它回答的是「曾是 strand」(写入时的历史事实),不是「此刻 restore 会成功」。两者在 (b) 类、以及引擎 journal 被逐出(MAX_CONSUMED_SUSPENSIONS = 50)后会分叉,分叉方向是多报。⚠️ 命名:⛔ 不要叫stranded。'stranded'是AutomationResult.status上已裁定的词汇,而裁定刻意没有把它扩到 run 行/ExecutionStatus;同名会和那条边界打架。
B′ 的先例成立,但有一个必须写进派发词的条件
listSuspendedRunsDurable?()确实已在ApprovalResumeSurface上(approval-service.ts:166-184),docblock 明说「declares a methodAutomationEngineALREADY implements publicly; it widens no engine surface」。⚠️ 一处不同:#15358 的 B′ 成员在引擎上尚不存在,要新增 ⇒ 不是「零引擎面」。⭐ 但 B′ 有一个只有它能做到的事:引擎里「此刻是否有可 restore 的消费快照」的唯一正确读法是
restoreConsumedSuspension的两证人读(热副本 vs 持久行、rowSupersedesJournal、persisted标志、drop notice;engine.ts:6604-6700)。那段注释自己写明单证人读法两个方向都测出过错。挂在ExecutionLogEntry上的字段结构上不可能做这件事(entry 是写入时快照 + rehydrate 派生)。⇒ B′ 是唯一能回答「此刻可修复」的形状;A / 第三形状回答的都是「曾是 strand」。
⛔ 但若 B′ 的成员被朴素实现成一次loadTerminal单证人读,会重演 #13937 契约复审已测出的假阴性——这一条必须进派发词。第四形状:已测量并排除
把
'stranded'加进ExecutionStatus并记到 run 行上。⛔ 不可取:新引擎配旧插件时,旧分类器的default → undefined会让所有 strand 从报告里静默消失——部署级假阴性。且裁定明确未扩这一词汇(changesetstranded-run-status-stamp.md:"that vocabulary isExecutionStatus… which the ruling did not widen")。列出仅为记录它被测过。降级安全性(判据未部署 / 引擎旧版 / 成员缺席)
形状 降级行为 安全? A / 第三形状 字段 undefined⇒ 分类器必须映射为今天未分化的'failed',⛔ 绝不可映射为 unrepairable✅(但生产点有两个: recordLog内存条目与runRecordToLogEntry持久条目,漏一个就在那条读路径上假阴性,须各自 pin)B′ 成员缺席 ⇒ 退回未分化 'failed'✅ ⚠️ 但这是新写的第三种降级:⛔ 不能抄hasSuspendedRun缺席时的return empty(那会让整份报告为空)第四形状 新引擎 + 旧插件 ⇒ strand 整体消失 ⛔ 不安全 复审自陈的未做
⛔ 未实际驱动
/data/sys_automation_run门验证受众等价性(以 #7900 docblock 为据);⛔ 未看到裁定原文,「合取」一说来自派发背景。⛔ 仍是复审,不是裁定。 车道席位不自选,四个形状的取舍在维护者。
Generated by Claude Code
Ruling recorded — B′: the discriminator is published as a dedicated read-only engine member, not on the wire; three report members, none folded (director seat, decision batch #76, 2026-09-07)
Maintainer reply, verbatim: 「同意」 — presented with B′ recommended.
Ruling.
- The batch Fix homepage font color and add blog section with in-depth analytical articles using fumadocs #36 ruling's item 1 (「
getRun加宽」) is amended to 「surface 加宽」:AutomationEnginegains a dedicated read-only member that answers repairability for a run id, andApprovalResumeSurfacedeclares it the waylistSuspendedRunsDurableis declared (optional; absence ⇒ the classifier falls back to today's undifferentiated'failed', ⛔ never tounrepairable, and ⛔ never toreturn empty). ⛔ Not A and not the derived-boolean shape: both publish onExecutionLogEntry, whichGET /automation/:name/runs/:runIdand the list endpoint serve verbatim with no projection (pinned), which collides with finding: a suspended flow run's variables are unreadable on run-detail — the pausedrecordLogomits them while the continuation keeps them #7639's refusal, and which can only ever answer "was a strand", not "is repairable now". - The new engine member must reuse
restoreConsumedSuspension's two-witness read (hot copy vs durable row,rowSupersedesJournal,persisted, drop notice —engine.ts:6604-6700) without re-arming. ⛔ A naive singleloadTerminalread is the false negative [Decision] Workflow resume ordering: a thrown node today leaves the run terminally unresumable — which of three shapes, given that the current order buys exactly-once across a crash? #13937's contract review already measured; the dispatch names this as a stop condition. StrandedRunStategets three members, not two: repairable (snapshot present), snapshot-dropped (consumedSuspensionDropped; repairable only by the stranding process while it lives, refused elsewhere with the budget explanation), unrepairable (neither: cascade-failed / never paused).'missing'untouched. ⛔ Folding the dropped class into "unrepairable" is service-automation: a throw betweenjournalConsumedSuspensionand thestatus: 'stranded'stamp makes a REPAIRABLE strand reportrepairable: false— the operator is told not to attempt a repair that works #15555's false-negative shape; folding it into "repairable" over-reports. Report it as what it is.- ⛔ The fourth shape (
'stranded'onExecutionStatus) is refused: old plugin + new engine makes every strand vanish from the report.
On the wording question raised by the contract review. The batch #36 ruling names the discriminator as
consumedSuspension/consumedSuspensionDroppedand uses neither 「都」 nor 「任一」. The card's "neither field ⇒ not a strand" is the engine docblock's own sentence (engine.ts:1324-1325) and is correct: mutual exclusivity rules out both present, not both absent; "neither present ⇒ not a strand" and "either present ⇒ was a strand" are the same statement. The review's "the conjunction is always false" is withdrawn as a correction; its substantive finding — the third class — is adopted in item 3.Execution (
domain:services, PR #16459 is the reproduction base; its PIN 1 must go red when this lands): two packages, no spec, no wire change;Clause-②: yeson the plugin's published types (ApprovalResumeSurface,StrandedRunState) ⇒needs:contract-reviewon the landing PR,@objectstack/plugin-approvalsminor. #15222 stays its own card and is the first consumer.pm:dispatched→pm:queue(the lane re-dispatches under this amendment).
Generated by Claude Code
- The batch Fix homepage font color and add blog section with in-depth analytical articles using fumadocs #36 ruling's item 1 (「
State restored to the recorded ruling —
needs-user-decision→pm:queue(director seat, summon #17,session_01XesLUWmuhjuRwmU618AZ1M, 2026-09-07T14:1xZ)The A / B′ fork this card's 09:31Z comment (5568565582) put to the maintainer was already ruled B′ at 08:28Z today: the discriminator is published as a dedicated read-only engine member, not on the wire; three report members, none folded (director seat, decision batch #76, comment 5567692623; maintainer reply, verbatim: 「同意」, presented with B′ recommended). The 09:31Z comment's own four-facet block also recommends B′, so it agrees with the ruling on substance; it re-labelled the card because it did not read the 08:28Z ruling, not because a new question arose. The ruling's own state transition was
pm:dispatched→pm:queuefor thedomain:serviceslane to re-dispatch under the amendment.Executed now:
needs-user-decision→pm:queuein one label write (read back), assignee clear. The second question in 5568565582 (criterion written against the single existing field, or also aconsumedSuspensionDroppedfield) is execution under B′: the ruling names "a dedicated read-only engine member" — one member, the absence-is-not-evidence discipline fail-closed on it, as that comment's own ③ facet describes; no second field. PR #16459 remains the reproduction base.
Generated by Claude Code
- added 3 commits that reference this issue
on Sep 9, 2026 - added a commit that references this issue
on Sep 28, 2026
Filed by the
domain:servicesexecution seat (sessionsession_01XpTx2tbq3pZRYAdoGt6E6Y,os-warren, seat post #6021) out of #13909's final dispatch, which was instructed to stop and report this fork rather than decide it. Unassigned;domain:*, type and priority are triage's.59c089149, 2026-09-01). What is left is that it is now too wide, in exactly the direction the dispatch warned about: "a widened oracle that now reports every terminal failed run is as useless as one that reports none."The measurement
ApprovalService.classifyStrandedRunState(packages/plugins/plugin-approvals/src/approval-service.ts:268-298) keys onrun.status === 'failed'.PR #15237 (
5964124dd) then gave the platform a strictly narrower discriminator, and the engine's own words call it authoritative —packages/services/service-automation/src/engine.ts:1287-1289: a terminal record with neitherconsumedSuspensionnorconsumedSuspensionDropped⇒ A cascade-failed run — the
failAncestorsshapeengine.ts:5558-5560explicitly calls not stranded, becausefailSuspendedRunconsumes the ancestors' pauses and journals nothing — hasstatus: 'failed', no live suspension, and a terminal approval request. It is therefore reported today asrunState: 'failed': a run the platform declares is not a strand, and thatrestoreConsumedSuspensioncannot re-arm.⛔ Not hypothetical — it is the subject of open #15222.
Why it is not mechanical, and why it is a decision rather than a dispatch
Two structural facts, both measured:
ApprovalResumeSurface.getRun(approval-service.ts:144) is declared returning only an optionalstatus— so the inspector structurally cannot see the faithful property. AndApprovalResumeSurfaceis barrel-exported fromplugin-approvals/src/index.ts.StrandedRunStateis'missing' | 'failed'(:319) and is barrel-exported too.⇒ Every route changes what the inspector promises, not merely what it finds — which is precisely the
Clause-②boundary #13909's dispatch drew as its stop-and-report line. ⇒ Contract-review tier,Clause-②: yes, whichever option is ruled.Options
A — Leave it; record the over-approximation as declared behaviour. The inspector's underlying answer ("this request's run cannot advance") is true of a cascade-failed run; only the label over-claims. Zero contract change. Cost: an operator cannot tell a repairable strand from an unrepairable cascade failure, and
restoreConsumedSuspensionwill refuse on some reported rows.B — Widen
ApprovalResumeSurface.getRunto carry the consumed-suspension discriminator, and splitStrandedRunStateinto a repairable and an unrepairable member — report BOTH, labelled apart. Cost: two public type changes.C — Narrow the oracle to the discriminator, so only
restoreConsumedSuspension-repairable runs are reported. Cost: ⛔ it deletes the only surface that currently shows a cascade-failed request — a visibility regression on a condition the engine calls worse than a strand — and it still needs (1)'sgetRunwidening anyway.consumedSuspension/consumedSuspensionDropped),而这个 inspector 键在一个更粗的属性上。让报告面与平台自己的词汇对齐,是缩小特例的方向;继续键在status === 'failed'上,则每新增一种终态形状,这个标签就再偏一点,而且偏移是静默的。①指向 B:一次把判据接上,而不是让每个未来的读者都得学一遍「这个标签比它的名字宽」。⛔ C 表面上也对齐了判据,但它用删掉一整类行的可见性来买对齐 —— 长远看这是把一个标注问题换成一个失明问题,更贵。restoreConsumedSuspensioncannot reach a nested run — a stranded child's cascade-failed ancestors are consumed without a snapshot, so restoring the child continues into a dead parent #15222 就是这个形状的 open 卡(嵌套 run 的 cascade-failed 祖先)。运维侧的可见后果也具体 —— 报出来的行里有一部分,运维照着去按修复动词会被拒绝。⇒ 按分歧推荐序,有实测拉动 ⇒ 荐长远终态一次付清,即 B。runState: 'failed',合理地推断这行可以用restoreConsumedSuspension修,然后被拒绝,而拒绝的理由与标签无关。B 把这变成响亮的结构性区分:不可修复的那类带自己的成员名,消费方在类型层面就被迫处理它。A 则把这条知识留在散文里 —— 而散文正是本车道今天已经被咬过两次的地方(一条描述对两个副本之一为真、却被读作对两者都为真)。#15222已经存在于板上,B 只是把已有的区分搬到消费方看得见的位置。相比之下 A 的「永久义务」是每个消费者都要记得这个标签比名字宽 —— 按本车道的经验,那种义务不会被记住。推荐:B。 四棱中①以≥50% 权重领起并指向 B;②有具名拉动、按分歧推荐序取长远终态;③B 是响亮/结构性的一侧;④的扩面成本真实但有界,且不引入新概念。
回退:A 是可辩护的暂缓 —— 今天报出来的集合没有一行是错的,只有它的标注过宽;若维护者判断标注精度暂时不值两个导出类型的变更,A 零风险且可随时改判。⛔ C 不建议:它是唯一会丢失运维需要的一行的选项。
置信缺口(本分析看不见什么): 未测量今天实际有多少 cascade-failed 行被报进来 —— 那需要真实部署上的运维普查,与 #13909 的定容问题同源(单独立卡)。这个数字只影响紧急度,⛔ 不翻转方向:B 在数字为零时也仍然是对的形状,只是不急。
Refs
#13909 (closed; this fork is what survived it) · #15222 (the nested-run instance of exactly this shape) · #13937 / PR #15237 (
5964124dd, the discriminator) · #13934 (59c089149, the widening that created the over-report) · #15336 (the grouped §5 finding — nearest family, but ⛔ does not state this) · #13807 (released downstream)⛔ Unaffected by the ADR-0131 tenancy pause — no tenant surface.