fix(plugin-approvals): report a run that failed mid-resume as stranded, and measure the resume-ordering fork (#13909) - #13934
Conversation
#13909) `inspectStrandedRequests` ended its check at `if (terminal) continue` — the existence of any run-history row read as health. The engine consumes a suspension before running downstream nodes (`forgetSuspendedRun(run, 'resumed')` precedes `traverseNext`), so a node that merely threw threw with the pause already gone and the catch arm wrote a terminal `failed` row: the evidence of the defect was being read as evidence of health, and the inspection reported 0 for the shape an operator most needs. The second oracle now classifies the run instead of merely detecting it. `failed` is reported with `runState: 'failed'`; `completed`, `cancelled` and `paused` are each still skipped for their own named reason, and an unrecognised status is skipped too. The first oracle and the read-only posture are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 5 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 9a130ac8b8567b405e3f58f863705387a5713b24 && git checkout 9a130ac8b8567b405e3f58f863705387a5713b24
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 479ace68938388ab93b4e865cc3ff6a632de8a74 d3f53f0b7485fee93543d373c6380add0aec0558 && git checkout -B drift-repro 479ace68938388ab93b4e865cc3ff6a632de8a74 && git merge --no-ff d3f53f0b7485fee93543d373c6380add0aec0558
node scripts/docs-audit/affected-docs.mjs --json 479ace68938388ab93b4e865cc3ff6a632de8a74
|
… line shift rotted (#13909) Pure line rot, repaired by `node scripts/check-system-context-census.mjs --fix`: the slice-1 edit to approval-service.ts inserted 77 lines above every anchored elevation read in that file, so all 8 anchors on census row 42 moved by exactly +77. The row's declared count (8 sites) is unchanged — the population did not move, only the lines did, which is why --fix accepted the repair instead of refusing it as a population change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Anchor rot repaired —
|
条款② 认定(
|
| 证据 | 位置 |
|---|---|
| 已发布包 barrel 新增导出 | packages/plugins/plugin-approvals/src/index.ts +export { type StrandedRunState } |
| 已导出 interface 新增必填字段 | StrandedApprovalRequest.runState: StrandedRunState(非可选) |
⇒ 扩大公开面,契约增量真实存在且可复审。你在 PR 正文里写的 "Declared for the reviewer's tier judgement, not self-cleared" 是对的做法——这一行判给 PM,现在裁定如下:
命中内容肢 + 派发档位低于契约复审档位 ⇒ ⛔ 本 PR 暂不入队。 已在 PR 与卡 #13909 双载体同笔挂 needs:contract-review;复审通过后由复审链同笔收口落地(清标即落地),⛔ 不需要你再做任何事。
补一句归因,不是对你的指摘:这张卡本该一开始就派在契约复审档位,派在默认档是 PM 席的选择失误,复审这一趟由此而来。#13951 已因同一原因走了一次 FAIL 返工。
Generated by Claude Code
契约复审:FAIL —— 逐字采纳,标签保留复审席资格,机读在案(⛔ 不是自述): 降档保险丝在本席自会话读回的 ⇒ 每一轮都在档、零回退 ⇒ 裁决合法。按「逐字采纳或整体作废」,取逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色: 契约复审:FAIL结论一行唯一阻塞:runId 字段注释被本次拓宽证伪,改一行注释即可发布。 阻塞项
非阻塞
我核了什么 / 我没核什么Q1 — 必填字段对消费者是否破坏性:是纯输出型,增量为加法。 搜索: Q2 — 词表、层级、落包:合格,不预占平台命名。 卡片交付 3 与 #13951 的表述都把平台命名留给后续(spec Q3 — 值集与产码一致:核实。 Q4 — 跨包机制断言:在今天的 origin/main(62a137b)上为真。 Q5 — 除阻塞项 1(正是低层级会漏的:diff 里没有 +/− 的一行被增量语义证伪)外,无制造发现。 我没核:⛔ 仓外 npm 消费者是否有人构造 PM 侧处置
Generated by Claude Code |
…d, and de-link a module-private symbol (#13909) Contract-review FAIL rework for #13934, blocking item 1 + non-blocking 1: - `StrandedApprovalRequest.runId` still carried the #4469-era sentence ("resolves to neither a suspension nor a run history row") that this PR's own widening falsifies — every `runState: 'failed'` row DOES resolve to a terminal history row, and the adjacent `runState` docblock said so two lines below. The field doc now forks on `runState`: no history row at all (`missing`), or a terminal `failed` row (`failed`). Comment only. - `{@link classifyStrandedRunState}` in the published `inspectStrandedRequests` docblock pointed at a module-private, non-exported function — a dead link in consumer-facing API docs. Both occurrences are now backtick code literals; the ordinary inline comment at the call site is unchanged. No behaviour change. Not done, per the PM's ruling on the review: the `StrandedRunState` closing pointer (#13937's unruled outcome) and the changeset's `patch` bump (recorded, not overturned). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Contract-review FAIL rework — landed at
|
契约复审(返工复核):PASS —— 逐字采纳,标签已清转录核验(采信前必做): ⇒ 每一轮都在档、零回退 ⇒ 裁决合法,按「逐字采纳或整体作废」取逐字采纳。以下为复审席原文,⛔ 未改写、未删节、未润色: 契约复审(返工复核):PASS结论一行阻塞项已清: 我核了什么 / 我没核什么核了(全部在 diff R1 — 阻塞项已清,且不是"删了了事"。 新 docblock(
三点都成立:(1) 对两个形态皆真 —— R2 — 纯注释,机器核实。 命令:用 TypeScript 编译器 scanner 对两版全文件逐 token 重排(排除 comment trivia 与 JSDoc AST 节点,类型 token 全保留), R3 — 无新增契约内容。 token 流全同 ⇒ 无新 export、无类型变更、无 optionality 变更。两处 R4 — 三个排除项均未动。 (a) STALE TREE 上报 — 属实,且不影响本复审。 没核什么:
PM 侧收口
⭐ 复审席那条 STALE TREE 读数值得单独记一笔,它把上报从「可能有影响」收敛成了「无影响」:54 个提交里 Generated by Claude Code |
…4 shift rotted (#13909) `check-system-context-census` was red on this branch: 16 problem(s) over 145 anchors and 109 census sites — 8 [site-without-a-row] plus 8 [anchor-is-not-a-read-site], all in packages/plugins/plugin-approvals/src/approval-service.ts, pairing up exactly and every pair moved by the same +4 lines (927/931, 1036/1040, 2993/2997, 3139/3143, 3306/3310, 3377/3381, 3566/3570, 3606/3610). The cause is this branch's own previous commit: the StrandedApprovalRequest `runId` docblock was rewritten from one line to five (7 insertions, 3 deletions — net +4), shifting every elevation read site below it in that file. Row 42 of content/docs/permissions/system-context.mdx had already been re-anchored once for the earlier head; the rework invalidated it again. Repaired mechanically with `node scripts/check-system-context-census.mjs --fix`, which rewrote exactly those 8 anchors and refused nothing — the same 8 sites, one uniform delta, none added or removed, which is the pure-shift case that tool is for. No anchor was hand-edited. The gate now exits 0 (109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read) and its --self-test leg passes.
|
os-dev-report {
"issue": 13934,
"status": "done",
"branch": "claude/issue-13909-resume-strand-visibility",
"pr": "https://github.com/objectstack-ai/objectstack/pull/13934",
"premise_still_valid": true,
"summary": "CI-red repair on PR #13934 (card #13909). The dispatch diagnosis was exact: check-system-context-census was red with 16 problems that pair into 8 stale page anchors, every pair a uniform +4 line shift in packages/plugins/plugin-approvals/src/approval-service.ts, caused by this branch own previous commit growing the StrandedApprovalRequest runId docblock from 1 line to 5. Repaired mechanically with the gate --fix, which rewrote exactly those 8 anchors on row 42 and refused nothing - a pure shift, not a population change. No anchor was hand-edited; the only file changed is content/docs/permissions/system-context.mdx (1 insertion, 1 deletion). The PR contract files (approval-service.ts, index.ts), the changeset and the tests were left untouched, so the contract-review PASS stands. Pushed as d3f53f0b7485fee93543d373c6380add0aec0558; PR left as-is (not flipped ready, auto-merge not enabled).",
"tests": "All runs in a dedicated worktree at the PR head (d987bbbe9), pnpm install exit 0 (9.4s).\n\n1) REPRODUCE — node scripts/check-system-context-census.mjs -> exit 1 (captured before any pipe)\n verdict line: \"check-system-context-census: 16 problem(s) over 145 anchors and 109 census sites.\"\n The 16 = 8 [site-without-a-row] (931,1040,2997,3143,3310,3381,3570,3610) + 8 [anchor-is-not-a-read-site]\n (927,1036,2993,3139,3306,3377,3566,3606), all in plugin-approvals/src/approval-service.ts.\n Diagnosis CONFIRMED: the shift is uniform +4 on every one of the 8 pairs, no exceptions.\n\n2) --fix — node scripts/check-system-context-census.mjs --fix -> exit 0. VERBATIM output:\n re-anchored content/docs/permissions/system-context.mdx:148 `plugin-approvals/src/approval-service.ts:927` -> `plugin-approvals/src/approval-service.ts:931`\n re-anchored content/docs/permissions/system-context.mdx:148 `:1036` -> `:1040`\n re-anchored content/docs/permissions/system-context.mdx:148 `:2993` -> `:2997`\n re-anchored content/docs/permissions/system-context.mdx:148 `:3139` -> `:3143`\n re-anchored content/docs/permissions/system-context.mdx:148 `:3306` -> `:3310`\n re-anchored content/docs/permissions/system-context.mdx:148 `:3377` -> `:3381`\n re-anchored content/docs/permissions/system-context.mdx:148 `:3566` -> `:3570`\n re-anchored content/docs/permissions/system-context.mdx:148 `:3606` -> `:3610`\n check-system-context-census --fix: 8 anchor(s) rewritten\n check-system-context-census: OK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.\n Nothing was refused: grep -niE \"refus|population|skip|left alone|cannot\" over the --fix log\n returned exit 1 (no match). All 8 rewrites landed on page line 148 (row 42), each exactly +4.\n\n3) RE-RUN without --fix -> exit 0:\n \"check-system-context-census: OK - 109 elevation read sites in 20 packages across 45 files,\n all anchored; 145 anchors resolve, 27 declared non-read.\"\n\n4) --self-test -> exit 0: \"check-system-context-census --self-test: all cases passed\"\n (includes \"FIX: a pure shift is rewritten\" and \"FIX: a population change is REFUSED, never guessed\").\n\n5) SCOPE — git diff --stat: content/docs/permissions/system-context.mdx | 2 +-, 1 file changed,\n 1 insertion(+), 1 deletion(-). git status --porcelain listed that one file and nothing else.\n The hunk is a single line (148): the 8 anchor numbers in row 42, prose untouched.\n approval-service.ts, index.ts, the changeset and the tests were NOT touched.\n\n6) DERIVED GATE FAMILY — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack\n (it derives over the whole branch changeset, 5 paths). Ran the docs-family gates my one-line\n edit could actually move, exit codes captured before any pipe, all exit 0:\n check:merge-driver 0 | check:corpus-claim-drift 0 | check:doc-anchors 0 | check:docs-single-h1 0 |\n check-doc-frontmatter 0 | check-docs-section-name 0 | check-doc-route-spelling 0 | check:nul-bytes 0.\n DECLARED NARROWING: the remaining families in that derivation are moved by the branch commits I did\n not touch (approval-service.ts, index.ts, the test, the changeset) and are unchanged by this commit;\n CI runs the full farm anyway.\n\n7) POST-COMMIT UNION — re-ran the census gate and its --self-test on the committed head\n d3f53f0b7 (git rev-parse --short HEAD from that run): both exit 0.",
"mcp_calls": "2 - one add_issue_comment for this report plus one read-back of it",
"open_questions": [],
"out_of_scope_findings": [],
"notes": [
"WORKTREE NOTE: the local branch claude/issue-13909-resume-strand-visibility was already checked out in a sibling agent worktree (/home/user/objectstack-13934-rework, clean, at d987bbbe9), so git worktree add with that branch name is refused by git. I used a detached worktree at d987bbbe9 and pushed with an explicit refspec (git push origin HEAD:refs/heads/claude/issue-13909-resume-strand-visibility) instead of push -u. The remote branch advanced normally; that sibling worktree local branch ref is now one commit behind and needs a fetch before it pushes again.",
"My worktree was removed cleanly afterwards (node_modules deleted first, git worktree remove without --force, exit 0).",
"FORMAT CONFLICT, declared per the standing dev contract: the dispatch asked for a prose report; the contract requires the final message to be exactly this JSON and requires a GitHub copy first. Every item the dispatch asked for is carried inside the tests field above."
]
}Generated by Claude Code Generated by Claude Code |
PM 裁定:CI 红已修,契约复审 PASS 依然成立,不需要第三轮复审
⭐ 而且这不是本席现在替复审席开脱 —— 复审席在裁决里就把这类改动划在了范围外,原话:「首轮已裁定的 Q1–Q5、消融方法、负例测试、changeset 行文、 这次红是什么,以及为什么它不是 flaky⛔ 不是 flaky,是本 PR 自己造成的,而且成因可以一句话说清:返工把
⇒ 纯位移(同样 8 个站点,同一个 delta,无增无减),不是 population change。这正是该门 验收读数(dev 报告,退出码均在管道之前捕获): ⭐ 这一轮真正的教训,记在账上
⇒ 「入队资格 = PR 上每一个 check 全绿,⛔ 不是 required 子集」这条不是洁癖,它今晚兑现了一次。本 PR 会等 Generated by Claude Code |
The merge of origin/main routed content/docs/permissions/system-context.mdx through the os-regen driver, which exits 0 without text-merging and leaves git's pre-filled OURS side in place. That silently dropped the 16 anchor re-points main had landed (#13829, #13934, #13910, #13857) while keeping this branch's single re-point. This commit takes main's side of the page and re-derives every anchor from the merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on both sides once line numbers are normalised, so nothing but line numbers moved.
…, so `rollbackToPackageCommit` stops planning off the weekday name (objectstack-ai#14036) * fix(metadata-protocol): order the ADR-0067 commit timeline by instant, not by the weekday name `created_at` is an engine-injected audit column: not in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only inside `if (this.isSqlite)`. The live SQL dialects therefore hand it out of the record read door as a JS `Date` while the SQLite family hands out canonical ISO-Z text. Both ADR-0067 commit-timeline consumers compared `String(created_at)`, and `String(aDate)` is `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"` — the LEADING token is the weekday NAME, so lexicographic order over those strings is `Fri < Mon < Sat < Sun < Thu < Tue < Wed`. Unrelated to chronology, and stable across the whole set, so it is wrong on every run and wrong the same way. - `listCommits` returned the timeline in weekday-name order while claiming newest-first; its own comment stated the assumption ("sort by the ISO timestamp") and it was false on the production default driver. - `rollbackToPackageCommit` both consumed that ordering and re-derived the same comparison itself, so neither site could correct the other: it reverted `apply` commits OLDER than the target and skipped the newer ones it exists to undo. Both sites now compare canonical absolute instants through `compareAuditInstants`, a sibling of the `canonicalVersionInstant` helper objectstack-ai#13382 landed one seam over in this same file. The canonicalisation is reused; the ordering is new, because `versionTokensAgree` answers equality between client-supplied version tokens and an ordering question needs `<`/`>`. When either side does not denote an instant the two are compared verbatim exactly as before, so only instant-bearing pairs change verdict. The pin drives a hand-made `Date` — `@objectstack/metadata-protocol` has no driver dependency and must not grow one — over four consecutive days, the smallest fixture for which no timezone alignment can make the old weekday comparison agree with chronology. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * chore(gates): re-point the isSystem census anchor and register the new engine double Both are the gates' own sanctioned repairs for the line/ledger movement the fix caused, applied with their own tooling and inspected: - `check-system-context-census --fix` RE-POINTED row 21's anchor `metadata-protocol/src/protocol.ts:1664` -> `:1736`, the 72-line shift the new `compareAuditInstants` helper block introduced above it. No row was deleted and no needle changed; the gate then reports 109 elevation read sites, 145 anchors resolving. - `check-engine-double-contract --write` ADDED one row recording that the new pin file pins 1 `findOne` double ("1 added or grown, 0 lost"). The shrink-only baseline is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * chore(docs): re-derive the isSystem census after merging origin/main The merge of origin/main routed content/docs/permissions/system-context.mdx through the os-regen driver, which exits 0 without text-merging and leaves git's pre-filled OURS side in place. That silently dropped the 16 anchor re-points main had landed (objectstack-ai#13829, objectstack-ai#13934, objectstack-ai#13910, objectstack-ai#13857) while keeping this branch's single re-point. This commit takes main's side of the page and re-derives every anchor from the merged tree with `pnpm gen:system-context-census`, which re-pointed row 21's metadata-protocol/src/protocol.ts anchor to 1736. Prose is byte-identical on both sides once line numbers are normalised, so nothing but line numbers moved. --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #13909 — slice 1 of two deliverables. The resume ordering is deliberately unchanged; deliverable (b) is a measured report, below, not a code change.
(a) The inspector can now see the shape — and still nothing else
ApprovalService.inspectStrandedRequestsscans['approved','rejected','returned'], so it always saw the row. Its second oracle threw the row away:The existence of any run-history row read as health. But in this shape the terminal row is written by the failure that stranded the request: the engine consumes the suspension before running the downstream nodes (
AutomationEngine.resumeInternalcallsforgetSuspendedRun(run, 'resumed')and only thentraverseNext), so a node that merely threw threw with the pause already gone, and the catch arm recordedfailed. The inspection therefore reported0for exactly the shape an operator most needs.The second oracle now classifies the run instead of merely detecting it (
classifyStrandedRunState).failedis reported withrunState: 'failed'; the pre-existing "no history row at all" shape is reported asrunState: 'missing'.The negatives are the load-bearing half, and each has its own reason and its own test:
failedrunState: 'failed')runState: 'missing')completedcancelledrecalledrequestpausedExecutionStatusvocabulary is wider than the four statuses the engine writes; a future status must not become a silent false positiveThe first oracle is untouched: a run the suspension store still holds is alive, and an unreadable store is still counted
undetermined, never condemned. The method is still read-only — no status is changed, no run is cancelled.The sweep's own warning now splits its counts (
runMissing/runFailed) because the two shapes need different remedies: amissingrun has no history to read, afailedone has a step log and an error naming the node that threw.Ablation — two mutations, both directions, direction predicted first
Committed first, mutated in the working tree, mutation confirmed on disk by blob hash and marker counts (never an editor's exit code), restored and proven by state. Baseline: 22 tests green.
Ablation A — revert the widening (
if (terminal) continuerestored,runStatedropped from the report). Predicted: the four pins that depend on the widening redden; every negative stays green, because the old code was strictly more conservative.2580f37c…→ mutated blob740a3ef9…(differs, so the mutation landed); markerclassifyStrandedRunState(terminal)1 → 0.Tests 4 failed | 18 passed (22)— exactly the predicted four: the two positives, "the OLD oracle would have skipped it", and the mixed-population pin.Ablation B — over-widen (classifier short-circuited to "anything not
completedis stranded"). Predicted: the negatives redden, the positives stay green.3b577cc8…; injected marker present once on disk.Tests 4 failed | 18 passed (22)—cancelled,paused, the unrecognised status, and the mixed-population pin.Restore proven by state after each leg:
git diff HEADempty,git hash-objectback to the HEAD blob2580f37c…, markers back to their HEAD counts.Declared controls, not ablation evidence — green in both directions and reported as such: the five negatives are green under A (the reverted code skips them too), and
completed+ "failed but still suspended" are green under B (B keepscompletedskipped, and the first oracle short-circuits before the classifier runs). "NEVER rewrites a stranded row" is green in both.(b) The ordering question, measured — REPORT ONLY, nothing here changes it
1. What #5512 requires, and whether running
traverseNextfirst would violate it#5512 is not a requirement about ordering relative to downstream work. Verbatim from the contract it created (
NodeExecutor.onSuspensionReleased,engine.ts): a pausing executor arms something external on entry (a one-shot wake-up job, a reminder, a lease), and until #5512 only its own wake path tore that down — so a pause ended by anything else left the armature live. What it requires is:forgetSuspendedRunis the only place every consumption (resumed/failed/cancelled) passes, "which is why it — and not any individual caller — notifies the paused node's executor";None of those three mentions
traverseNext. ⇒ Running the downstream nodes before consuming the suspension would not violate #5512 as written. What it would violate is the requirement stated in the line directly above the call, which is a different one:That is an exactly-once-across-a-crash property, and #5512's teardown is a rider on it, not its purpose. The in-process
this.resumingguard already covers concurrent duplicates (RESUME_IN_PROGRESS); the ordering is what covers a process that dies mid-traversal.There is one #5512 clause the ordering does carry, and it is decisive for shape 3 below — from
wait-node.ts's ownonSuspensionReleased:The teardown is allowed to cancel the wake-up only because the pause is definitively gone.
2. The third shapes, and what each costs
Shape 2 — consume on success only (
traverseNextfirst,forgetSuspendedRunin the success path).hasSuspendedRunanswers true for the whole traversal window, which changesApprovalService.assertRunResumable: a second decision arriving mid-resume is refused today (RESUME_TARGET_LOST) and would be admitted under shape 2, landing a durable decision row that then collides withRESUME_IN_PROGRESS. (iii) thewaitone-shot stays armed for the traversal window and can fire mid-traversal — harmless in-process (RESUME_IN_PROGRESS), not across processes. (iv) it silently changes what amapre-entry and a re-suspend see in the store, sincepersistSuspendedRunwould now overwrite a row that is still live rather than write a fresh one.Shape 3 — consume, then re-arm on a caught throw (compensating re-persist in the
catch).catchruns,releaseSuspensionhas already told the executor the pause is over and thewaitexecutor has cancelled its one-shot job. Re-persisting the suspension therefore produces a timer wait that nothing will ever wake — a silent forever-pause, which is [17.0-rc2验收] wait 定时暂停被外部 resume 短路后,一次性唤醒 job 仍保持 armed —— 次日对已完成 run 发起幽灵 resume,sys_job 里留下误导性的「待唤醒」行 #5512's defect inverted and strictly worse than a visiblefailed. Making it correct needs a re-arm half of the contract (onSuspensionRearmed, or re-entering the executor), i.e. a new public hook onNodeExecutorthat every pausing node type must implement. (ii) It needs a terminal-state answer: if every throw re-arms, a genuinely broken node retries forever and the run never reachesfailed— an attempt counter, a poison threshold and a channel for the error become part of the design. (iii) Exactly-once holds per pause, not per node: side effects performed by the partial traversal before the throw re-run on the next resume.Shape 4 — leave the ordering alone; add an explicit operator verb (the parent card's deliverable 2: re-arm / retry a terminally failed run, as a deliberate action).
⇒ Recommendation for the next slice, on the four axes: shape 4 first, and shape 2 only with a durable claim/lease that keeps exactly-once. Shape 3 is the one to rule out early — its true cost is a new mandatory hook on the
NodeExecutorcontract, which is a public-surface widening bought to paper over an ordering.3. What else depends on the current ordering
Five resume callers, all reaching the identical arm (re-verified on this tree by symbol):
plugin-approvalsserviceResume/resumeRecordedOutcome— reportsRESUME_FAILED, HTTP 500, decision already durable.POST /api/v1/automation/:name/runs/:runId/resume(packages/runtime/src/domains/automation.ts) — HTTP 400FLOW_FAILED. Its own in-place comment is the decisive evidence: "Every arm above is a REFUSAL that left the suspension intact and can be retried; what reaches HERE consumed its pause and ran." Any ordering change moves the boundary that comment describes, and the route's arm-by-arm status mapping (404 / 409 / 503 / 400) is written against it.wait-node.ts— the timer job firing. ItskeepArmed/STORE_UNAVAILABLEbranch is already the precedent for "a wake-up that fired without consuming the pause stays armed"; a new "the pause survived a throw" outcome needs its own answer here or the one-shot is spent.wait-node.tsrearmSuspendedWaitTimers— cold-boot re-arm of an overdue run. This is the caller that reads the durable rows shape 2 would leave behind after a crash.resumeInternal(childRunId, signal, true)) andbubbleToParent(resumeInternal(parentRunId, sig, false, summary)). A parent and child both mid-resume mean two suspensions in flight in one call stack, andfailAncestorsfails every ancestor on a terminal child failure.Readers of
hasSuspendedRunmid-resume (the whole non-test population, measured):ApprovalService.assertRunResumable— the pre-flight that refuses to record a decision against a run that cannot advance (RESUME_TARGET_LOST). It readsfalseduring the whole downstream traversal today, and would readtrueunder shape 2. This is the single most behaviour-visible dependency outside the engine.ApprovalService.inspectStrandedRequests— the first oracle, in this PR. Under shape 2 a run mid-traversal reads as suspended, so this inspection would skip it — correct, but it means the window this PR makes visible is defined by the ordering too.Verification
pnpm --filter @objectstack/plugin-approvals test→ 34 files, 641 tests passed; the inspection suite alone 22 passed (13 pre-existing + 9 new).pnpm --filter @objectstack/plugin-approvals typecheck→ clean.tsconfig.jsonexcludes**/*.test.ts, measured withtsc --listFiles(edited test file: 0 hits; positive controlapproval-service.ts: 1), sotypechecksays nothing about the test file. The test file is measured bycheck:type-check-debt --re-measure, which builds a temp project over the hidden test files: OK, 29 ledger entries re-measured, none above its recorded number.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no paths passed — the script takes the change set from the merge base), both sections read whole: 30 path-matched families + the convention-triggered sets for a test-file edit and for a package owning ani18n-extract.config.ts.8e2ec8fa9, exit codes captured before any pipe. Includes every convention-triggered ratchet:check:engine-double-contract(OK — 727 pinned; no new double, this PR extends an existing test file),check:where-matcher,check:query-options-erasure,check:cross-package-test-inputs,check:type-check-coverage,check:type-check-debt, pluscheck:i18nandcheck:dual-build-cjs-loads(both re-run green afterturbo run buildcleared their prerequisite),check:published-files,check:test-source-alias,check:nul-bytesand a manual control-byte grep over all four changed files (no hits).check-test-completeness.mjsexit 3 — "PREREQUISITE NOT MET — this gate grades a savedturbo run testlog", which CI tees and a local run cannot produce. CI's to run.pnpm lint(repo-wideeslint . --no-inline-config) → green at8e2ec8fa9. Run whole, so no narrowing to declare.8e2ec8fa9, which is this branch's final commit.Measured / NOT MEASURED
Measured in-repo: the oracle's old blindness and its new verdicts, both directions, by ablation; the five resume callers and the
hasSuspendedRunreaders, by symbol on this tree; what #5512 requires, from the contract it created.sys_automation_run(status = 'failed') joined againstsys_approval_request(terminal status —approved/rejected/returned— withflow_run_idset). Until that number exists, the remedy in the next slice must not be sized from zero.Scope and surface
forgetSuspendedRunandtraverseNextare untouched — verify from the diff: no file underpackages/services/service-automationis in it.POST /api/v1/approvals/requests/{id}/rejectreturns 500 while its effect lands AND strands the workflow run — three inconsistent outcomes from one call #13807's, deliberately sequenced after this card).packages/spec/**(dispatch-gatesconfirms "no path-derived mandate"), but it does add to a published package's public surface — one exported type aliasStrandedRunStateand one field on the exportedStrandedApprovalRequest. That interface is an output-only reporting shape the service produces and no caller in this repo constructs. Giving the condition itself a platform-level name would land inpackages/spec/src/contracts/automation-service.ts, so it is deliberately not done here and stays with the next slice — the report label is documented in place as "not a run state".Generated by Claude Code
Generated by Claude Code