test(plugin-approvals): reproduce #15358 — a cascade-failed ancestor is reported as the repairable strand it is not - #16459
Conversation
…is reported as the repairable strand it is not One fixture drives both shapes against a real AutomationEngine and a real ApprovalService: a child run stranded mid-continuation (repairable) and its parent, cascade-failed while parked at its subflow node (unrepairable). `inspectStrandedRequests` returns both as `runState: 'failed'`, while `restoreConsumedSuspension` re-arms one and refuses the other `NO_CONSUMED_SUSPENSION`. PIN 3 records the measurement that sends the ruled option B back for one more decision: `AutomationEngine.getRun` answers an `ExecutionLogEntry`, which carries neither `consumedSuspension` nor `consumedSuspensionDropped` for EITHER run — the engine keeps the snapshot off that interface on purpose, because `GET /automation/:name/runs/:runId` serves it verbatim. So widening the plugin-side declaration alone cannot separate the two rows. PIN 4 is the door control: the same strand carries `repairable: true` through `decide` and no envelope at all through `recall`. Part of #15358 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…double-contract ledger `check:engine-double-contract` RETAINED both verbs of the new `stranded-run-repairability.test.ts` double: it already routes `update` and `delete` through ObjectQL's own dispatch predicates, so the ledger just had to learn about the file. Regenerated with `--write`; 2 rows added, 0 lost. Part of #15358 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
PM 独立复核 —— 逐条自己量的,没有采信 seat 的读数复核在一个独立 worktree( 1. 交付面 —— PASS
2. 四个 pin —— PASS,但第一遍是 UNMEASURED 不是绿第一次直接跑得到 3. 我自己做的两次消融消融 A —— 分类器臂(复现 seat 的读数): 还原后 blob 回到 消融 B —— PIN 3 的反空转(seat 没做这一条):在 ⇒ 这一条同时证了两件事:PIN 3 的 (插一句方法学:还原源文件后不重建 4. ⛔ 一条 seat 没报、而且是我自己喂错的:
|
⛔ 撤回:上一条 PM 复核的第 4 节是错的。这个 PR 的引文是准确的上一条评论( 我错在哪儿(机制,不是措辞)我的 grep 跑在共享主 checkout 的工作树上。那棵树停在
在正确的树上逐处读到:
而那句被我说成「没有任何文件写过」的话,逐字写在该字段自己的 docblock 里(
⇒ 本 PR 文件头的引用是准确的。 我准备的那个「修正」补丁如果推上去,会把一句正确的引擎引文换成一句错的 —— 正好是它自称要消除的那类错误。 随之作废的两条
复核里没有受这次污染、仍然成立的部分这几条都是在正确的树上取的(PR head 的独立 worktree
⇒ PR 的 PASS 认定不变, 我改的规矩⛔ 主 checkout 的工作树不再用于任何测量。它是别的会话在动的共享树,HEAD 会在脚下漂。本席后续所有源码读数只走两条路:正确 base 的专用 worktree,或 Generated by Claude Code |
落地判定:必需上下文实测 6 条全绿;
|
Part of #15358
⛔ This PR does NOT implement the ruled option B. It lands the measurement that option B was ruled on, and it reports one premise that the drive falsified. The card stays open; see "The fork" below.
What is here
One new pin file,
packages/plugins/plugin-approvals/src/stranded-run-repairability.test.ts, driving a realAutomationEngineand a realApprovalService(approvals tables on an in-memory ObjectQL double whose write verbs route through ObjectQL's own dispatch predicates), plus the ledger rowcheck:engine-double-contractasks for that double.One fixture produces both shapes at once.
deal_subflowparks at anapproval; its approve edge leads to asubflownode hostingpost_approval, which parks at a secondapprovalwhose approve edge throws.restoreConsumedSuspensionrunStatetodayrestored: truefailedsubflownode, cascade-failed byfailAncestorsNO_CONSUMED_SUSPENSIONfailedThe parent's own decision succeeded — the pin asserts
resumed: trueon it before the cascade — so its laterfailedrow is not a decision that never moved. That is the over-report the card is about, now measured rather than read off the sources.PIN 1 asserts both labels in a single
toEqual, deliberately: whatever lands for option B must turn it red, and a split that relabels only one of the two rows still fails it.The fork — why option B's first clause could not be executed here
The ruling's clause 1 is "
getRunwidens to carry the discriminator the engine already records". The engine recordsconsumedSuspension/consumedSuspensionDroppedon the durableRunRecord.AutomationEngine.getRunanswers anExecutionLogEntry, and PIN 3 measures that this object carries neither field, for either run — with a positive control in the same assertion block so the absences are absences in a real terminal record, not the shape of a null.That is deliberate on the producer's side, not an oversight:
recordLogstates the snapshot is "a parameter rather than a field ofExecutionLogEntry" because that interface "is served verbatim byGET /automation/:name/runs/:runId" — and the runtime route does serve it verbatim, with no schema strip.⇒ Widening only the plugin-side declaration cannot separate these two rows. On a real engine the discriminator is absent for both, so a classifier reading absence as "not a strand" answers UNREPAIRABLE for the repairable row — telling an operator not to attempt a repair that succeeds, which is the #15555 harm shape one surface over. So the ruled split needs a producer-side decision first, and there are two readings of where the discriminator gets published:
ExecutionLogEntryplus its spec declaration, after which the plugin reads it throughgetRunexactly as ruled. Cost: a new key on a published HTTP response, for every automation API consumer.ApprovalResumeSurfacethe waylistSuspendedRunsDurablealready is. Cost: the ruling's "getRunwidens" becomes "the surface widens", and there is one more optional member whose absence has to be handled.Those differ in what the platform promises to every automation API caller, so this seat did not pick one. ⛔ No public type in
plugin-approvalsis touched by this PR.Also measured (the door control)
PIN 4 drives the same strand through two doors in one run:
decidethrowsRESUME_FAILEDcarrying{ finalized, decision, runId, repairable: true }(#13807), whilerecallreports the same strand as a bareresumeErrorstring with no envelope at all —strandedDecisionDetailson the recall result isundefined. The difference is the door, not the strand.Verification
Final head
9f4e3ed55.pnpm --filter @objectstack/plugin-approvals test— 42 files, 694 tests, all passing.pnpm --filter @objectstack/plugin-approvals typecheck— green; its third leg (check:test-typecheck,tsconfig.test.json) is the one that covers the new file, because the package's owntsconfig.jsonexcludes**/*.test.ts.classifyStrandedRunState'scase 'failed'arm mutated to returnundefined, proven on disk by the injected marker count and a changed blob hash (141b6300becomes6975962a); the run went red on PIN 1 only (1 failed, 3 passed), which is the expected direction — PINs 2 to 4 read the engine, not the classifier. Restored withgit checkout HEAD -- ABSOLUTE_PATH, proven bygit diff HEADempty, the blob hash back at141b6300, and the marker gone. The plugin's own source resolves fromsrchere (relative import), so no rebuild sits between the mutation and the reading; the engine resolves throughexportstodist, which was built before every run.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsand run in full — 56 commands, every one exit 0, each exit code captured immediately after its own single redirection.check:engine-double-contractwas genuinely red first (the new double was unledgered) and is green after the--writeregeneration committed here. Three families answered exit 3PREREQUISITE NOT MET(check:dual-build-cjs-loads,check:i18n,check:type-check-debt); the workspace build was run and all three re-run to real green readings —check:type-check-debtneeds an 8192 MB heap in this container.No changeset
Test file plus a gate ledger — nothing any package publishes changes. Same shape as the most recent test-only landing in this tree (
d5df8168f, one new pin file, no changeset).skip-changesetapplied.验收备注
recall's stranded exit carries no machine-readable repairability signal. Measured in PIN 4: the same strand that makesdecidethrow an envelope withrepairable: trueleavesrecallreturningresumed: falseplus a bareresumeErrorstring, andstrandedDecisionDetailson that result isundefined. ⛔ Noted, NOT filed:recalldeliberately tolerates a run it cannot resume (its own doc says the withdrawal and the record-lock release are the point), and no declared contract promises the envelope at this door — so this is a characterisation, outside the three filing classes. It is pinned in PIN 4 so it cannot drift unnoticed, and it is the door half of thePOST /api/v1/approvals/requests/{id}/rejectreturns 500 while its effect lands AND strands the workflow run — three inconsistent outcomes from one call #13807 family if anyone widens that ruling.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 is open and is the same shape this PR reproduces — its cascade-failed ancestor answersNO_CONSUMED_SUSPENSION, exactly as PIN 2 measures. ⛔ Nothing about it is folded into this PR.strandedDecisionDetailson the recall result to benull; the measured value isundefined. Reported rather than smoothed over.