fix(plugin-approvals, service-automation): report a repairable strand, a dropped snapshot and a cascade-failed run apart — through a read-only engine member, not the wire (#15358 B′) - #16704
Conversation
…classifier + pins Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…randed-discriminator
📓 Docs Drift CheckThis PR changes 2 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e62fa145519fc3a60c97e50225c08ad6d2bcc8c8 && git checkout e62fa145519fc3a60c97e50225c08ad6d2bcc8c8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2e6a2ea4c90b2f1e8c86a2228d025e85be95836c 42ce7bf4d847f098bb9e420ae1f94d6a3f7f3f74 && git checkout -B drift-repro 2e6a2ea4c90b2f1e8c86a2228d025e85be95836c && git merge --no-ff 42ce7bf4d847f098bb9e420ae1f94d6a3f7f3f74
node scripts/docs-audit/affected-docs.mjs --json 2e6a2ea4c90b2f1e8c86a2228d025e85be95836c
|
…ampling instant, the strict read's one incidental eviction, and the third cause behind NO_CONSUMED_SUSPENSION Prose-only patch round from the #15358 contract review (items 1, 6, 7): no executable line changes. - resolveConsumedSuspensionWitnesses TSDoc: the hot copy is sampled after the caller's loadTerminal await, where the inline read it replaced sampled before it — same decision table, later instant; an eviction during that await answers a refusal, never a double-run. - inspectConsumedSuspension TSDoc: 'drops nothing' softened — loadSuspendedRunStrict may evict a phantom suspendedRuns entry (#15832), identical to hasSuspendedRun; the journal is never touched. - 'unrepairable' / NO_CONSUMED_SUSPENSION prose: names the third cause (snapshot no longer held) beside cascade-failed and never-paused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Fixes #15358
Ruling implemented: B′ (director seat, decision batch #76, 2026-09-07T08:28Z, comment 5567692623; maintainer reply verbatim 「同意」). The discriminator is published as a dedicated read-only engine member, declared on
ApprovalResumeSurfacethe waylistSuspendedRunsDurablealready is. The wire surface is untouched: no new key onExecutionLogEntry, nopackages/specchange, nothing added toGET /automation/:name/runs/:runId(PIN 3 of the reproduction now pins that). Reproduction base: #16459 (merged) — its PIN 1 went red on this tree and was flipped to the resolved expectation.The defect
ApprovalService.classifyStrandedRunStatekeyed onrun.status === 'failed'. A cascade-failed run — an ancestorfailAncestorsfailed while parked at itssubflownode, whose pausefailSuspendedRunconsumed and journalled nothing — has the same terminalfailedrow as the #13909 strand, so the inspection reported both asrunState: 'failed'whilerestoreConsumedSuspensionre-arms one and refuses the other (NO_CONSUMED_SUSPENSION). Open instance: #15222 remains open and is the first consumer.What landed
@objectstack/service-automation(engine.ts,index.ts)AutomationEngine.inspectConsumedSuspension(runId)— read-only. Answers from the SAME two witnessesrestoreConsumedSuspensionreads (this process's hot journal vs the durable row;rowSupersedesJournal,persisted, drop notice). The two-witness read is now one private method,resolveConsumedSuspensionWitnesses, called by both verbs, so they cannot disagree. The restore verb keeps the same decision table (its stale-hot-copy drop is now driven by the helper'sstaleHotflag) with one instant moved: the hot copy is now sampled after theloadTerminalawait rather than before it, so a journal eviction (MAX_CONSUMED_SUSPENSIONS) landing during that await answers a refusal where the old read restored from the copy it had already captured — direction is refusal, never a double-run (the helper's TSDoc says so; contract-review item 1). Not a naive singleloadTerminalread — ruling item 2 — and the ablation below shows the pins catch exactly that regression.repairable: true(+flowName/nodeId/correlation/witness/consumedAt),SNAPSHOT_DROPPED(+ the drop notice),NO_CONSUMED_SUSPENSION(cascade-failed, never paused, or a snapshot no longer held — the verb does not say which),RUN_SUSPENDED(+nodeId). REJECTS when a store read fails — same posture ashasSuspendedRun. It re-arms nothing and writes nothing; its one incidental mutation is the strict suspension read's own phantom-entry eviction (service-automation: two loose ends on the #14333 claim path — the loser's hot-cache entry is never evicted, and the'unsupported'branch answers only after its conditional delete has already been issued #15832,suspendedRunsonly, never the journal) — identical tohasSuspendedRun(review item 7).ConsumedSuspensionInspectionexported from the barrel, for the same reasonSuspensionRestoreResultis.@objectstack/plugin-approvals(approval-service.ts)ApprovalResumeSurface.inspectConsumedSuspension?(runId)— optional, declaring a method the engine already implements; widens no engine surface.StrandedRunState— the three report members, none folded, with'missing'untouched:'repairable'(service-automation: a resume consumes the pause BEFORE running downstream nodes, so any node that throws leaves the run terminally unresumable — and the only inspector for it reports all clear #13909 strand),'snapshot_dropped'(its own class — asrepairableit over-reports, asunrepairableit is a false negative),'unrepairable'(cascade-failed / never paused / snapshot no longer held, 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's shape among them — the label is faithful to the verb: nothing re-arms it; review item 6).'failed'stays a member on purpose — see fail-closed below.inspectStrandedRequestsasks the third oracle forfailedrows only (pinned: exactly one read, for exactly the failed-and-not-suspended row). The summary log addsrunRepairable/runSnapshotDropped/runUnrepairable.Fail-closed on absence — the load-bearing property
Absence of the discriminator is NOT read as "not a strand". A surface that lacks
inspectConsumedSuspensionleaves afailedrow reported as today's undifferentiated'failed'— never'unrepairable'(that calls the repairable row dead: the #15555 false-negative harm one surface over), never skipped (that hides the row) — exactly the ruling's item 1 fallback. A thrown read countsundetermined, like the other two oracles. Pinned twice: on the real engine (stranded-run-repairability.test.tsPIN 5 — the same two rows through a surface carrying only the two older oracles come back['failed', 'failed']; re-attaching the full surface restores the split, as the positive control) and on a scripted surface (stranded-request-inspection.test.ts, the #15358 block: absent member, thrown read, unknown reason word,RUN_SUSPENDEDskip, only-asked-for-failed-rows call count). An answer word this build does not know also stays'failed'.The two PM readings re-measured, with controls
Both readings in the dispatch (comment 5572530604, from 5568565582) were false at every tree measured —
bc0ac1d1f(this branch's base),8341ed21c(the base the dispatch named), andorigin/mainatba5284e60:consumedSuspensionDroppedhas zero hits repo-wide (no producer, no consumer, no test)". Measuredgit grep -n consumedSuspensionDropped→ 18 hits in 7 files: declared onRunRecord(engine.ts:1331), produced bysuspended-run-store.ts:796(readConsumedSuspensionDropNotice), consumed byrestoreConsumedSuspension(engine.tsdrop-notice arm), tested instranded-run-object-store.test.ts:297-300, exported typeConsumedSuspensionDropNoticeinindex.ts. Introduced by5964124dd(feat(automation): stampstatus: 'stranded'on the resume catch arm and pin the re-armed run's exactly-once — the #13937 services half (shape 4) #15237). Positive control:consumedSuspension→ 29 hits inengine.tsalone. At8341ed21c:git grep -c consumedSuspensionDropped 8341ed21c -- packages→ 6 files.git grep 'was not a strand'→ 0 hits, but only because the sentence wraps:engine.ts:1323-1325reads "a terminal record with neither field is a run that reached a terminal state which was not [newline] a strand (completed, cancelled, cascade-failed), or never paused at all." The sentence exists; the single-line grep was defeated by the line break.Consequence, executed under the ruling's own words rather than the dispatch's reading of them: no field was built (the ruling's "one member" is the engine member, and there is one), but the existing
consumedSuspensionDroppedfield is READ — ruling item 3 requires the snapshot-dropped class as its own member, and folding it either way is named there as the wrong shape. The contract review (PASS, zero blocking findings) judged the five-memberStrandedRunStateCONFORMS to the ruling's verbatim text.Prose-only patch round (contract review items 1, 6, 7) — commit
42ce7bf4dThree TSDoc edits and this body; no executable line changed, so the PASS against
1b4dad617carries across. Proof:git diff 1b4dad617..HEAD -- '*.ts'→ 36 changed lines, of which 0 are non-comment lines (every changed line, after stripping the sign and indentation, begins with a comment token);git diff --stat=approval-service.ts+7/−2,engine.ts+24/−3. Typecheck of both packages re-run green on the patch (check:test-typecheck: OKeach; the plugin ledger unchanged). Review item 4 (refineFailedRunStaterunning outside thetry) is a behaviour change and is deliberately left exactly as it is, for the follow-up card.Verification (all on
1b4dad617, this branch's head at review = merge oforigin/mainba5284e60; the later42ce7bf4dchanges comment lines only)pnpm --filter @objectstack/service-automation typecheckand... plugin-approvals typecheck— both green (check:test-typecheck: OKfor each; plugin debt ledger unchanged at 8 files / 324 / 27 pinned).pnpm --filter @objectstack/service-automation test—Test Files 125 passed (125) · Tests 1486 passed (1486);pnpm --filter @objectstack/plugin-approvals test—Test Files 42 passed (42) · Tests 705 passed (705). Both packages' full suites; downstream importers owe no test (see consumers).packages/services/service-automation/src/consumed-suspension-inspection.test.ts(10 tests: journal and durable witnesses,RUN_SUSPENDED, never-paused, snapshot-cleared-after-finish, the dropped snapshot from the stranding replica vs a fresh one, both store outages reject, barrel nameability + exhaustive switch);stranded-run-repairability.test.tsPIN 1 flipped + PIN 5 added;stranded-request-inspection.test.ts[Decision]inspectStrandedRequestsnow over-reports: it keys onstatus === 'failed'while the platform gained an authoritative strand discriminator — a cascade-failed run the engine calls NOT stranded is reported as one #15358 block (10 tests).dist): PIN 1 with the pre-B′ expectation['failed', 'failed']re-applied →Tests 1 failed | 4 passed (5),AssertionError: expected [ [ …(2) ], …(1) ] to deeply equal …; restored withgit checkout HEAD -- …,git hash-object= HEAD blob613c8f6b…both sides,git diff HEAD --statempty.resolveConsumedSuspensionWitnesses): on-diskgrep -coriginal line 1→0, marker 0→1;pnpm --filter @objectstack/service-automation buildexit 0;ablation-dist-preflight"marker present in 2 built files"; engine pinsTests 2 failed | 8 passed(the journal pin and the "stranding replica still answers repairable from its hot copy" pin — the ruling's stop condition, caught). The plugin repro stayed green under this mutation by construction (its in-memory store row carries the snapshot), which is why the engine-side pins exist.journalConsumedSuspensionand thestatus: 'stranded'stamp makes a REPAIRABLE strand reportrepairable: false— the operator is told not to attempt a repair that works #15555 inversion, in the engine): marker aglobalThisproperty so esbuild cannot fold it (a first attempt with a string-literal comparison was constant-folded out ofdistand its reading discarded, as was an earlier non-compiling attempt); preflight "marker present in 2 built files"; engine pinsTests 3 failed | 7 passed; pluginTests 2 failed | 35 passed— PIN 1 and PIN 5 red.git checkout HEAD -- engine.ts, blobb00f251d…= HEAD, whole-treegit status --porcelainempty, rebuild, preflight--absent"marker absent from all 6 built files", engine 10/10 and plugin 37/37 green.node scripts/pm/dispatch-gates.mjs --commands(no paths; merge-base self-derived, identical list pre- and post-merge) → 59 commands, all exit 0 after the packages closure was built;--ran→ "59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED". Three of them (check:dual-build-cjs-loads,check:i18n,check:type-check-debt) answeredPREREQUISITE NOT MET(exit 3) before the closure build and green after it — not counted as red.pnpm check:error-code-casing(not derived; a new reason literal) green.pnpm lint(repo-level eslint) not run locally — CI'sLint & Repo Gates.Consumers of the changed exports (import edges, with a positive control)
Import edges of
@objectstack/plugin-approvalsoutside the package (git grep "from '@objectstack/plugin-approvals"overpackages/**,examples/**,apps/**): exactly one —examples/app-showcase/test/approval-resume-relation-expand.test.ts:52importsApprovalsServicePlugin(the positive control). Zero importStrandedRunState,ApprovalResumeSurfaceorStrandedApprovalRequest; the remaining mentions are prose (a spec TSDoc, a CHANGELOG line, andautomation-result-status.pin.test.tsasserting the docblock namesStrandedRunState). Theservice-automationbarrel addition is purely additive.../objectuiis not checked out in this container and was not measured.Changeset
Judged against AGENTS.md's changeset rule: both packages publish, both diffs change published behaviour and published types →
.changeset/stranded-run-state-discriminator.md,minorfor each (additive members; the ruling names@objectstack/plugin-approvalsminor).skip-changesetdoes not apply.验收备注
wip:commits were pushed before the coordinator asked for real messages; rewriting pushed commits needs a force-push, which this lane forbids, so they stand (ruled). This PR title and body carry the real message; the prose patch commit42ce7bf4dcarries a real one.packages/spec/src/contracts/automation-service.ts:345TSDoc saysStrandedRunStateis'missing' | 'failed'— now stale prose; the ruling forbids a spec change in this PR, and it is a docs nit, not a defect.stranded-run-repairability.test.tsPIN 3 pins thatgetRuncarries neither discriminator field — under B′ that is now the intended contract (wire untouched), so the pin's role flipped from "measurement" to "guard"; its docblock says so.inspectConsumedSuspensiondoes not judge therestoring/resuminginstant-safety guards (documented); a caller acting onrepairable: truestill goes through the restore verb, which does.CONTRACT_REVIEW_TIERread fromscripts/pm/dispatch-gates.mjs=claude-fable-5-1.Clause-②: yes—needs:contract-reviewon issue and PR.Attribution: authored in Claude Code session
session_012zTkyNHJ7TkuN2oXtP5x37.