fix(service-automation): keep a dying loop's completed body steps in the run log - #14185

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure
Sep 1, 2026
Merged

fix(service-automation): keep a dying loop's completed body steps in the run log#14185
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13803

A loop that dies mid-sweep discarded its body's completed steps wholesale. The engine
splices a container's childSteps into the run's step list only after a successful
node result, and a loop whose body throws never produces a result at all — the throw
unwinds straight past the splice, taking the accumulated array with the stack frame.

Reproduced on this branch's base (682d03ba7), not from the card's 43e18f51 cite, with
the real AutomationEngine and the card's own five-row shape (row 3 has a null owner, so
notify fails):

flagged = ["c1","c2","c3"] notified = ["c1","c2"] -> 5 writes really happened
summary = { selected: 5, acted: 0, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure) ] -> no record of any of them

The direction is the defect, not the arithmetic. An operator reading acted: 0 on a
failed sweep reasonably concludes "nothing happened, safe to re-run" — but the writes did
happen. For a non-idempotent body (notifications, counters, external calls) that misread
invites double-execution, and for auditing the rows exist in the data with no step record
to explain them. The run summary is the platform's own honesty instrument (#4354), and on
this path it was wrong in the one direction that causes harm.

Why this fix and not the other two

The card named three unexamined candidates. All three were measured before choosing.

A summary-side repair — rejected on evidence.summarizeRun is a pure fold over the
flat step log, and on the failing path that log holds three entries: start, query, and
the loop's own failure step, which carries no metrics. The counts are not
under-aggregated, they are absent. A summary-side repair could therefore only invent
numbers it has no record of, or degrade to reporting the sweep as unmeasured — and the
honest version of the latter needs a field on the summary, which is
packages/spec/src/contracts/automation-service.ts and the clause-② path limb. Rejected
without writing code: the data has to be preserved, not reinterpreted.

Splice childSteps before result grading — measured, and it is a no-op here. This one
was implemented in isolation and run, because the reasoning is easy to get wrong. Adding
the childSteps splice to the engine's if (!result.success) branch changes nothing on
this path: the loop does not return a failing result, it throws, so the engine's
catch (execErr) arm runs instead and there is no result object to splice from. The
measured summary after that change was still { selected: 5, acted: 0 }, and the step log
was still three entries. The tell is visible in the log without the experiment: the
loop's step carries EXECUTION_ERROR, which only the catch arm emits. The mutation was
confirmed on disk by blob hash and marker count before the run, and reverted with
git diff HEAD proven empty afterwards.

Flush per-iteration — the right instinct, in the wrong place. For the loop to write
body steps into the run log as it goes, it needs the run's step array, and
NodeExecutor.execute(node, variables, context) does not receive it. Reaching it means
widening the executor contract for every executor to serve one container.

What shipped keeps the per-iteration instinct but uses the seam #7546 already built.
runRegion grew a partialSteps sink for exactly this — surfacing a failed region's
partial steps — and wired only try_catch to it, noting that "callers that do not pass a
sink (loop, parallel) are unaffected". So:

  1. loop now passes its childSteps accumulator as that sink, which captures the
    failing iteration's steps too (success returns them instead, so nothing is counted
    twice), and
  2. the dying container carries the accumulated array out on the thrown error as a
    non-enumerable symbol brand, which the engine's catch arm folds into the run log in the
    same position the success path splices childSteps — behind the container's own step,
    ahead of any fault handler's.

The brand is not a new idea in this package: #3863's markGuardRefusal already carries
"this failure is un-routable" out through this identical throw path the same way.
#7546 declined the exception channel for runRegion because it "would either change what
callers catch or require a bespoke error type" — neither cost lands here, since this is
the opposite direction (executor back to engine) and the error object is passed through
untouched rather than wrapped.

What it means under partial failure

The run still fails. Nothing about accept/reject behaviour moves — that was the deciding
constraint, and it is why the losing alternative lost. Having loop swallow the failure
and return { success: false, childSteps } would have made the engine's failure branch
work, but it would also rewrite the run's error text (Node 'notify' failed: ... becomes
Node 'each' failed: Node 'notify' failed: ...), change the container step's code from
EXECUTION_ERROR to NODE_FAILURE, set $error where it previously stayed unset, and —
decisively — make a guard refusal raised inside a loop body routable by a fault edge on
the loop. That is precisely the one-edge switch #3863 exists to prevent. A record fix must
not move accept/reject behaviour, so the error is rethrown with its identity, message and
guard-refusal marking intact.

What changes is only the record, and only for iterations that actually ran:

summary = { selected: 5, acted: 5, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure),
flag@0 ok, notify@0 ok, flag@1 ok, notify@1 ok, flag@2 ok, notify@2 FAILURE ]

Rows 4 and 5 were never entered and no step claims they were. The per-node breakdown now
lists the body nodes that ran (flag runs 3 acted 3; notify runs 3 failures 1 acted 2)
instead of only the container. Every folded step carries a parentNodeId set by
runRegion's tagger, so the ADR-0044 runaway guard — which counts only top-level visits —
cannot see them either.

Nesting is handled rather than assumed: as a failure unwinds through nested containers each
one re-brands the same error with its own accumulator, and because an outer container's
sink has already absorbed the inner one's steps, the last write is a superset of every
earlier one and each step object still reaches the log exactly once. The brand is writable
for this reason — a non-writable one would make the second container to unwind throw a
TypeError, turning a nested-loop failure into an engine crash. There is a test for it.

Verification

All readings below are at dbc1d661a, the branch head.

The directional assertion is the pin.expectNeverUnderReports asserts the reported
acted is never lower than the writes the store actually holds, rather than that it
equals 3 or 5 — the direction survives a refactor, the literal does not. Under ablation it
reads:

reported acted=0 is LOWER than the 5 writes that actually happened —
an operator reading this concludes "nothing happened, safe to re-run"

Reverse control. A sweep whose body's first node fails writes nothing and must still
report acted: 0, because there that is the honest answer. It does, and it stays green
under the ablation — it is insensitive to this fix by construction, which is exactly what
stops the repair degenerating into copying selected into acted. A second reverse case
pins the boundary: failing on the first element after one write reports acted: 1, not 0
and not selected.

Controls, declared as controls and not offered as ablation evidence. An all-succeeding
sweep and the try_catch-contained path (#13681's face) were captured before and after the
change and are byte-identical — the measured lines diff clean, sha 6b1579d5... and
7bf71866... respectively. The failing run's propagation fields (success, error,
run.status) are byte-identical too.

Ablation. Direction predicted first: removing the engine-side fold should redden the
six tests that read the recovered record and leave the four that do not. Measured exactly
that — 6 failed, 4 passed, and the four greens were the predicted ones. The mutation was
confirmed on disk before the run (deleted-text count 1 to 0, injected marker 0 to 1, blob
b5e4b3f5... to 19682f89...), never from an editor's exit code, and the restore was
proven by state afterwards (git diff HEAD empty, blob back to b5e4b3f5...), not by a
trap firing. No rebuild leg applies: the subject is reached through relative same-package
imports (../engine.js, ./loop-node.js), so vitest resolves source, never dist
confirmed empirically, since editing only service-automation/src without rebuilding that
package changed the measured result.

Suites and gates.

  • pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=295
    files, 1133 tests, all pass
    .
  • Gate family re-derived from the actual diff with
    node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read
    whole: 30 path-derived plus 7 convention-triggered. 34 of 35 pass. The one exception
    is scripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET locally
    because it grades a tee'd turbo run test log that only CI produces; its own output says
    to record it as NOT MEASURED and that it "is not a red". Recorded as NOT MEASURED, not as
    a pass. Every exit code was captured before any pipe.
  • pnpm check:type-check-debt (the --re-measure ratchet) passes with the workspace
    closure built, so the ledgered count did not drift.
  • pnpm lint (eslint . --no-inline-config, whole repo) — exit 0, 64s. No narrowing.
  • pnpm check:nul-bytes — clean over 7741 files; the diff was also scanned directly for
    raw control bytes.
  • tsc --listFiles lists all four touched sources, the new test file included, and the
    only errors reported are the three pre-existing ledgered TS2341 in
    nested-region-parity.test.ts, a file this PR does not touch.

Scope

Deliberately not addressed here, and each remains open: #13681's ruled B-branch is the
contained path's docs and lint visibility, whereas this is the uncontained path's
summary under-reporting; the resume ordering, forgetSuspendedRun and traverseNext are
#13937's decision surface in the same file; and restoreConsumedSuspension is untouched.
No file under packages/spec/ is modified, so the declared shapes of AutomationResult,
the run summary and steps are unchanged — StepLogEntry is engine-local.

One adjacent finding went to its own card, #14184, and remains open: try_catch with no
catch region returns a failing result and deliberately withholds its childSteps, citing
the same engine asymmetry. This PR closes that asymmetry for the throw path only and leaves
the engine's returned-failure branch alone, since no executor returns childSteps there
today and a fold for zero producers would be speculative — so a try_catch with no handler
still loses its try-region record.

Generated by Claude Code


Generated by Claude Code

…the run log
A `loop` that died mid-sweep discarded its body's `childSteps` wholesale: the
engine splices them into the run's step list only after a SUCCESSFUL node
result, and a loop whose body throws never produces a result at all. A run that
genuinely performed 3 flag writes and 2 notifies before dying reported
`{selected: 5, acted: 0}` and kept no step record of writes already committed.
The danger is the direction, not the arithmetic: `acted: 0` on a failed sweep
reads as "nothing happened, safe to re-run", which for a non-idempotent body
invites double-execution.
A dying container now carries its completed body steps out on the thrown error
-- a non-enumerable symbol brand, the idiom #3863 already uses to mark
un-routable guard refusals on this same throw path -- and the engine's catch
path folds them into the run log where the success path splices `childSteps`.
The error is rethrown unchanged, so failure propagation, `fault`-edge routing,
`$error` and the run's reported error are all untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from bfe2575a0eca030611b87310217e96d824541ca8 — the merge of head dbc1d661a112f353bb9e05132f5bcb00e144821a into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bfe2575a0eca030611b87310217e96d824541ca8 && git checkout bfe2575a0eca030611b87310217e96d824541ca8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 dbc1d661a112f353bb9e05132f5bcb00e144821a && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff dbc1d661a112f353bb9e05132f5bcb00e144821a
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
Collaborator

PM 复核:通过 —— 落地前检已过,CI 全绿即转 ready + auto-merge

落地前检按最终 5 条路径当场重跑:0 of 5 path(s) hit the register ⇒ 非受管面。
Clause-②: no 采纳,并已独立复核:5 个文件无一在 packages/spec/**;partial-steps.ts 虽有导出,但 src/index.ts不在 diff 里,本席另查桶文件确认它不引用该模块 ⇒ 模块内部,未扩大发布面

⭐ 本轮最值钱的不是修复,是你证伪了两条候选修法

派发令说「三条路都未考察,自己量、自己选、写明为什么是这条」。你真的量了,而且量出了反直觉的结果:

  1. 「splice childSteps before result grading」—— 卡自己的第一条建议 —— 你孤立实现后实测它是 no-op。 理由干净利落:loop 是而不是返回,引擎走 catch 臂,根本没有 result 对象可评级,摘要照旧 acted: 0。⇒ 一个只读卡片就动手的实现会照着这条做,然后得到一个看起来合理、实际什么都没修的 diff。
  2. summary-side 修复 —— 你判它「计数是缺失,不是欠聚合」,而诚实版本需要给摘要加字段 ⇒ 那是条款②路径肢。⇒ 你不是绕开了停手条件,是选了一条不触发它的正确路
  3. 「flush per-iteration」 —— 需要 run 的 step 数组,而 NodeExecutor.execute 拿不到。

⭐ 而被否掉的第四条,理由是语义安全,不是风格

The alternative shape — having loop swallow the failure and return { success: false } — was rejected for precisely this reason: it would have made a guard refusal raised inside a loop body routable by a fault edge on the loop, the one-edge switch #3863 exists to prevent.

⇒ 一个「记录级」修复若顺手把 loop 改成返回失败,就会把不可路由的守卫拒绝变成可路由的。这是行为面的改变,而本卡是记录面的卡。看出这一点需要读 #3863 为什么存在,不是读 loop 的代码就能得到。

证据形状,逐条对上

  • 方向性 pin 就是卡本身:expectNeverUnderReports 断言 reported acted永不低于库里实际写数,而不是「现在等于 5」。ablation 下它打印的失败信息本身就是这张卡:"reported acted=0 is LOWER than the 5 writes that actually happened — an operator reading this concludes 'nothing happened, safe to re-run'"。⇒ 断言的措辞把危害方向写进了失败输出。
  • 反向控制做到了它该做的:首节点即失败、零写入 ⇒ 仍报 acted: 0(那里 0 才是诚实答案),且在 ablation 下保持绿 —— 按构造对本修复不敏感。⇒ 这正是那条防「把 selected 抄给 acted」的闸;你还补了第二个反向例(首元素写了一次才失败 ⇒ 报 1,不是 0 也不是 3)。
  • 控制项前后逐字节比对:全成功 sweep 与 try_catch 包住的路径(A loop node aborts the entire flow run when one iteration's node fails — a single bad row kills a whole scheduled sweep, and there is no per-iteration containment to opt into #13681 的面)取了 sha 对比(6b1579d58b3b9404 / 7bf718661e8be578),失败运行的传播字段(successerrorrun.status)也逐字节相同。
  • 嵌套不重复计数有专测:loop 套 loop,断言 acted恰好等于真实写数(折叠两次会把它顶到真实值之上,等式能抓到),并断言 new Set(steps).size === steps.length
  • 无 rebuild 腿是被证明的而非假设:只改 service-automation/src 不重建就改变了测得结果(acted 0 → 5)⇒ vitest 走源码,dist 不在解析路径上。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿未当红。

⚠️ 一处设计选择值得记下来,因为它是「差点变成崩溃」的那种

brand 被刻意设为可写,注释说明了原因:不可写的 brand 会让第二个展开的容器抛 TypeError,把一次嵌套 loop 失败变成引擎崩溃。⇒ 一个看起来更"严格"的选择在这里是错的,而你把理由写在了代码旁边,不是留给下一个人重新踩。

衍生卡

#14184 已定级路由:bug · p3 · domain:services · pm:queue —— try_catchcatch 区时在返回失败路径上丢弃 try 区 step 记录,是同一处不对称的另一半。⭐ 你刻意只修抛出路径、把返回失败那半留着不动(理由:今天没有任何 executor 在那条路上返回 childSteps,折叠会是投机性的)—— 这个边界画得对,记在案。


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(service-automation): keep a dying loop's completed body steps in the run log - #14185

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure
Sep 1, 2026
Merged

fix(service-automation): keep a dying loop's completed body steps in the run log#14185
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13803

A loop that dies mid-sweep discarded its body's completed steps wholesale. The engine
splices a container's childSteps into the run's step list only after a successful
node result, and a loop whose body throws never produces a result at all — the throw
unwinds straight past the splice, taking the accumulated array with the stack frame.

Reproduced on this branch's base (682d03ba7), not from the card's 43e18f51 cite, with
the real AutomationEngine and the card's own five-row shape (row 3 has a null owner, so
notify fails):

flagged = ["c1","c2","c3"] notified = ["c1","c2"] -> 5 writes really happened
summary = { selected: 5, acted: 0, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure) ] -> no record of any of them

The direction is the defect, not the arithmetic. An operator reading acted: 0 on a
failed sweep reasonably concludes "nothing happened, safe to re-run" — but the writes did
happen. For a non-idempotent body (notifications, counters, external calls) that misread
invites double-execution, and for auditing the rows exist in the data with no step record
to explain them. The run summary is the platform's own honesty instrument (#4354), and on
this path it was wrong in the one direction that causes harm.

Why this fix and not the other two

The card named three unexamined candidates. All three were measured before choosing.

A summary-side repair — rejected on evidence.summarizeRun is a pure fold over the
flat step log, and on the failing path that log holds three entries: start, query, and
the loop's own failure step, which carries no metrics. The counts are not
under-aggregated, they are absent. A summary-side repair could therefore only invent
numbers it has no record of, or degrade to reporting the sweep as unmeasured — and the
honest version of the latter needs a field on the summary, which is
packages/spec/src/contracts/automation-service.ts and the clause-② path limb. Rejected
without writing code: the data has to be preserved, not reinterpreted.

Splice childSteps before result grading — measured, and it is a no-op here. This one
was implemented in isolation and run, because the reasoning is easy to get wrong. Adding
the childSteps splice to the engine's if (!result.success) branch changes nothing on
this path: the loop does not return a failing result, it throws, so the engine's
catch (execErr) arm runs instead and there is no result object to splice from. The
measured summary after that change was still { selected: 5, acted: 0 }, and the step log
was still three entries. The tell is visible in the log without the experiment: the
loop's step carries EXECUTION_ERROR, which only the catch arm emits. The mutation was
confirmed on disk by blob hash and marker count before the run, and reverted with
git diff HEAD proven empty afterwards.

Flush per-iteration — the right instinct, in the wrong place. For the loop to write
body steps into the run log as it goes, it needs the run's step array, and
NodeExecutor.execute(node, variables, context) does not receive it. Reaching it means
widening the executor contract for every executor to serve one container.

What shipped keeps the per-iteration instinct but uses the seam #7546 already built.
runRegion grew a partialSteps sink for exactly this — surfacing a failed region's
partial steps — and wired only try_catch to it, noting that "callers that do not pass a
sink (loop, parallel) are unaffected". So:

  1. loop now passes its childSteps accumulator as that sink, which captures the
    failing iteration's steps too (success returns them instead, so nothing is counted
    twice), and
  2. the dying container carries the accumulated array out on the thrown error as a
    non-enumerable symbol brand, which the engine's catch arm folds into the run log in the
    same position the success path splices childSteps — behind the container's own step,
    ahead of any fault handler's.

The brand is not a new idea in this package: #3863's markGuardRefusal already carries
"this failure is un-routable" out through this identical throw path the same way.
#7546 declined the exception channel for runRegion because it "would either change what
callers catch or require a bespoke error type" — neither cost lands here, since this is
the opposite direction (executor back to engine) and the error object is passed through
untouched rather than wrapped.

What it means under partial failure

The run still fails. Nothing about accept/reject behaviour moves — that was the deciding
constraint, and it is why the losing alternative lost. Having loop swallow the failure
and return { success: false, childSteps } would have made the engine's failure branch
work, but it would also rewrite the run's error text (Node 'notify' failed: ... becomes
Node 'each' failed: Node 'notify' failed: ...), change the container step's code from
EXECUTION_ERROR to NODE_FAILURE, set $error where it previously stayed unset, and —
decisively — make a guard refusal raised inside a loop body routable by a fault edge on
the loop. That is precisely the one-edge switch #3863 exists to prevent. A record fix must
not move accept/reject behaviour, so the error is rethrown with its identity, message and
guard-refusal marking intact.

What changes is only the record, and only for iterations that actually ran:

summary = { selected: 5, acted: 5, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure),
flag@0 ok, notify@0 ok, flag@1 ok, notify@1 ok, flag@2 ok, notify@2 FAILURE ]

Rows 4 and 5 were never entered and no step claims they were. The per-node breakdown now
lists the body nodes that ran (flag runs 3 acted 3; notify runs 3 failures 1 acted 2)
instead of only the container. Every folded step carries a parentNodeId set by
runRegion's tagger, so the ADR-0044 runaway guard — which counts only top-level visits —
cannot see them either.

Nesting is handled rather than assumed: as a failure unwinds through nested containers each
one re-brands the same error with its own accumulator, and because an outer container's
sink has already absorbed the inner one's steps, the last write is a superset of every
earlier one and each step object still reaches the log exactly once. The brand is writable
for this reason — a non-writable one would make the second container to unwind throw a
TypeError, turning a nested-loop failure into an engine crash. There is a test for it.

Verification

All readings below are at dbc1d661a, the branch head.

The directional assertion is the pin.expectNeverUnderReports asserts the reported
acted is never lower than the writes the store actually holds, rather than that it
equals 3 or 5 — the direction survives a refactor, the literal does not. Under ablation it
reads:

reported acted=0 is LOWER than the 5 writes that actually happened —
an operator reading this concludes "nothing happened, safe to re-run"

Reverse control. A sweep whose body's first node fails writes nothing and must still
report acted: 0, because there that is the honest answer. It does, and it stays green
under the ablation — it is insensitive to this fix by construction, which is exactly what
stops the repair degenerating into copying selected into acted. A second reverse case
pins the boundary: failing on the first element after one write reports acted: 1, not 0
and not selected.

Controls, declared as controls and not offered as ablation evidence. An all-succeeding
sweep and the try_catch-contained path (#13681's face) were captured before and after the
change and are byte-identical — the measured lines diff clean, sha 6b1579d5... and
7bf71866... respectively. The failing run's propagation fields (success, error,
run.status) are byte-identical too.

Ablation. Direction predicted first: removing the engine-side fold should redden the
six tests that read the recovered record and leave the four that do not. Measured exactly
that — 6 failed, 4 passed, and the four greens were the predicted ones. The mutation was
confirmed on disk before the run (deleted-text count 1 to 0, injected marker 0 to 1, blob
b5e4b3f5... to 19682f89...), never from an editor's exit code, and the restore was
proven by state afterwards (git diff HEAD empty, blob back to b5e4b3f5...), not by a
trap firing. No rebuild leg applies: the subject is reached through relative same-package
imports (../engine.js, ./loop-node.js), so vitest resolves source, never dist
confirmed empirically, since editing only service-automation/src without rebuilding that
package changed the measured result.

Suites and gates.

  • pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=295
    files, 1133 tests, all pass
    .
  • Gate family re-derived from the actual diff with
    node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read
    whole: 30 path-derived plus 7 convention-triggered. 34 of 35 pass. The one exception
    is scripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET locally
    because it grades a tee'd turbo run test log that only CI produces; its own output says
    to record it as NOT MEASURED and that it "is not a red". Recorded as NOT MEASURED, not as
    a pass. Every exit code was captured before any pipe.
  • pnpm check:type-check-debt (the --re-measure ratchet) passes with the workspace
    closure built, so the ledgered count did not drift.
  • pnpm lint (eslint . --no-inline-config, whole repo) — exit 0, 64s. No narrowing.
  • pnpm check:nul-bytes — clean over 7741 files; the diff was also scanned directly for
    raw control bytes.
  • tsc --listFiles lists all four touched sources, the new test file included, and the
    only errors reported are the three pre-existing ledgered TS2341 in
    nested-region-parity.test.ts, a file this PR does not touch.

Scope

Deliberately not addressed here, and each remains open: #13681's ruled B-branch is the
contained path's docs and lint visibility, whereas this is the uncontained path's
summary under-reporting; the resume ordering, forgetSuspendedRun and traverseNext are
#13937's decision surface in the same file; and restoreConsumedSuspension is untouched.
No file under packages/spec/ is modified, so the declared shapes of AutomationResult,
the run summary and steps are unchanged — StepLogEntry is engine-local.

One adjacent finding went to its own card, #14184, and remains open: try_catch with no
catch region returns a failing result and deliberately withholds its childSteps, citing
the same engine asymmetry. This PR closes that asymmetry for the throw path only and leaves
the engine's returned-failure branch alone, since no executor returns childSteps there
today and a fold for zero producers would be speculative — so a try_catch with no handler
still loses its try-region record.

Generated by Claude Code


Generated by Claude Code

…the run log
A `loop` that died mid-sweep discarded its body's `childSteps` wholesale: the
engine splices them into the run's step list only after a SUCCESSFUL node
result, and a loop whose body throws never produces a result at all. A run that
genuinely performed 3 flag writes and 2 notifies before dying reported
`{selected: 5, acted: 0}` and kept no step record of writes already committed.
The danger is the direction, not the arithmetic: `acted: 0` on a failed sweep
reads as "nothing happened, safe to re-run", which for a non-idempotent body
invites double-execution.
A dying container now carries its completed body steps out on the thrown error
-- a non-enumerable symbol brand, the idiom #3863 already uses to mark
un-routable guard refusals on this same throw path -- and the engine's catch
path folds them into the run log where the success path splices `childSteps`.
The error is rethrown unchanged, so failure propagation, `fault`-edge routing,
`$error` and the run's reported error are all untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from bfe2575a0eca030611b87310217e96d824541ca8 — the merge of head dbc1d661a112f353bb9e05132f5bcb00e144821a into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bfe2575a0eca030611b87310217e96d824541ca8 && git checkout bfe2575a0eca030611b87310217e96d824541ca8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 dbc1d661a112f353bb9e05132f5bcb00e144821a && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff dbc1d661a112f353bb9e05132f5bcb00e144821a
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
Collaborator

PM 复核:通过 —— 落地前检已过,CI 全绿即转 ready + auto-merge

落地前检按最终 5 条路径当场重跑:0 of 5 path(s) hit the register ⇒ 非受管面。
Clause-②: no 采纳,并已独立复核:5 个文件无一在 packages/spec/**;partial-steps.ts 虽有导出,但 src/index.ts不在 diff 里,本席另查桶文件确认它不引用该模块 ⇒ 模块内部,未扩大发布面

⭐ 本轮最值钱的不是修复,是你证伪了两条候选修法

派发令说「三条路都未考察,自己量、自己选、写明为什么是这条」。你真的量了,而且量出了反直觉的结果:

  1. 「splice childSteps before result grading」—— 卡自己的第一条建议 —— 你孤立实现后实测它是 no-op。 理由干净利落:loop 是而不是返回,引擎走 catch 臂,根本没有 result 对象可评级,摘要照旧 acted: 0。⇒ 一个只读卡片就动手的实现会照着这条做,然后得到一个看起来合理、实际什么都没修的 diff。
  2. summary-side 修复 —— 你判它「计数是缺失,不是欠聚合」,而诚实版本需要给摘要加字段 ⇒ 那是条款②路径肢。⇒ 你不是绕开了停手条件,是选了一条不触发它的正确路
  3. 「flush per-iteration」 —— 需要 run 的 step 数组,而 NodeExecutor.execute 拿不到。

⭐ 而被否掉的第四条,理由是语义安全,不是风格

The alternative shape — having loop swallow the failure and return { success: false } — was rejected for precisely this reason: it would have made a guard refusal raised inside a loop body routable by a fault edge on the loop, the one-edge switch #3863 exists to prevent.

⇒ 一个「记录级」修复若顺手把 loop 改成返回失败,就会把不可路由的守卫拒绝变成可路由的。这是行为面的改变,而本卡是记录面的卡。看出这一点需要读 #3863 为什么存在,不是读 loop 的代码就能得到。

证据形状,逐条对上

  • 方向性 pin 就是卡本身:expectNeverUnderReports 断言 reported acted永不低于库里实际写数,而不是「现在等于 5」。ablation 下它打印的失败信息本身就是这张卡:"reported acted=0 is LOWER than the 5 writes that actually happened — an operator reading this concludes 'nothing happened, safe to re-run'"。⇒ 断言的措辞把危害方向写进了失败输出。
  • 反向控制做到了它该做的:首节点即失败、零写入 ⇒ 仍报 acted: 0(那里 0 才是诚实答案),且在 ablation 下保持绿 —— 按构造对本修复不敏感。⇒ 这正是那条防「把 selected 抄给 acted」的闸;你还补了第二个反向例(首元素写了一次才失败 ⇒ 报 1,不是 0 也不是 3)。
  • 控制项前后逐字节比对:全成功 sweep 与 try_catch 包住的路径(A loop node aborts the entire flow run when one iteration's node fails — a single bad row kills a whole scheduled sweep, and there is no per-iteration containment to opt into #13681 的面)取了 sha 对比(6b1579d58b3b9404 / 7bf718661e8be578),失败运行的传播字段(successerrorrun.status)也逐字节相同。
  • 嵌套不重复计数有专测:loop 套 loop,断言 acted恰好等于真实写数(折叠两次会把它顶到真实值之上,等式能抓到),并断言 new Set(steps).size === steps.length
  • 无 rebuild 腿是被证明的而非假设:只改 service-automation/src 不重建就改变了测得结果(acted 0 → 5)⇒ vitest 走源码,dist 不在解析路径上。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿未当红。

⚠️ 一处设计选择值得记下来,因为它是「差点变成崩溃」的那种

brand 被刻意设为可写,注释说明了原因:不可写的 brand 会让第二个展开的容器抛 TypeError,把一次嵌套 loop 失败变成引擎崩溃。⇒ 一个看起来更"严格"的选择在这里是错的,而你把理由写在了代码旁边,不是留给下一个人重新踩。

衍生卡

#14184 已定级路由:bug · p3 · domain:services · pm:queue —— try_catchcatch 区时在返回失败路径上丢弃 try 区 step 记录,是同一处不对称的另一半。⭐ 你刻意只修抛出路径、把返回失败那半留着不动(理由:今天没有任何 executor 在那条路上返回 childSteps,折叠会是投机性的)—— 这个边界画得对,记在案。


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(service-automation): keep a dying loop's completed body steps in the run log - #14185

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure
Sep 1, 2026
Merged

fix(service-automation): keep a dying loop's completed body steps in the run log#14185
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13803

A loop that dies mid-sweep discarded its body's completed steps wholesale. The engine
splices a container's childSteps into the run's step list only after a successful
node result, and a loop whose body throws never produces a result at all — the throw
unwinds straight past the splice, taking the accumulated array with the stack frame.

Reproduced on this branch's base (682d03ba7), not from the card's 43e18f51 cite, with
the real AutomationEngine and the card's own five-row shape (row 3 has a null owner, so
notify fails):

flagged = ["c1","c2","c3"] notified = ["c1","c2"] -> 5 writes really happened
summary = { selected: 5, acted: 0, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure) ] -> no record of any of them

The direction is the defect, not the arithmetic. An operator reading acted: 0 on a
failed sweep reasonably concludes "nothing happened, safe to re-run" — but the writes did
happen. For a non-idempotent body (notifications, counters, external calls) that misread
invites double-execution, and for auditing the rows exist in the data with no step record
to explain them. The run summary is the platform's own honesty instrument (#4354), and on
this path it was wrong in the one direction that causes harm.

Why this fix and not the other two

The card named three unexamined candidates. All three were measured before choosing.

A summary-side repair — rejected on evidence.summarizeRun is a pure fold over the
flat step log, and on the failing path that log holds three entries: start, query, and
the loop's own failure step, which carries no metrics. The counts are not
under-aggregated, they are absent. A summary-side repair could therefore only invent
numbers it has no record of, or degrade to reporting the sweep as unmeasured — and the
honest version of the latter needs a field on the summary, which is
packages/spec/src/contracts/automation-service.ts and the clause-② path limb. Rejected
without writing code: the data has to be preserved, not reinterpreted.

Splice childSteps before result grading — measured, and it is a no-op here. This one
was implemented in isolation and run, because the reasoning is easy to get wrong. Adding
the childSteps splice to the engine's if (!result.success) branch changes nothing on
this path: the loop does not return a failing result, it throws, so the engine's
catch (execErr) arm runs instead and there is no result object to splice from. The
measured summary after that change was still { selected: 5, acted: 0 }, and the step log
was still three entries. The tell is visible in the log without the experiment: the
loop's step carries EXECUTION_ERROR, which only the catch arm emits. The mutation was
confirmed on disk by blob hash and marker count before the run, and reverted with
git diff HEAD proven empty afterwards.

Flush per-iteration — the right instinct, in the wrong place. For the loop to write
body steps into the run log as it goes, it needs the run's step array, and
NodeExecutor.execute(node, variables, context) does not receive it. Reaching it means
widening the executor contract for every executor to serve one container.

What shipped keeps the per-iteration instinct but uses the seam #7546 already built.
runRegion grew a partialSteps sink for exactly this — surfacing a failed region's
partial steps — and wired only try_catch to it, noting that "callers that do not pass a
sink (loop, parallel) are unaffected". So:

  1. loop now passes its childSteps accumulator as that sink, which captures the
    failing iteration's steps too (success returns them instead, so nothing is counted
    twice), and
  2. the dying container carries the accumulated array out on the thrown error as a
    non-enumerable symbol brand, which the engine's catch arm folds into the run log in the
    same position the success path splices childSteps — behind the container's own step,
    ahead of any fault handler's.

The brand is not a new idea in this package: #3863's markGuardRefusal already carries
"this failure is un-routable" out through this identical throw path the same way.
#7546 declined the exception channel for runRegion because it "would either change what
callers catch or require a bespoke error type" — neither cost lands here, since this is
the opposite direction (executor back to engine) and the error object is passed through
untouched rather than wrapped.

What it means under partial failure

The run still fails. Nothing about accept/reject behaviour moves — that was the deciding
constraint, and it is why the losing alternative lost. Having loop swallow the failure
and return { success: false, childSteps } would have made the engine's failure branch
work, but it would also rewrite the run's error text (Node 'notify' failed: ... becomes
Node 'each' failed: Node 'notify' failed: ...), change the container step's code from
EXECUTION_ERROR to NODE_FAILURE, set $error where it previously stayed unset, and —
decisively — make a guard refusal raised inside a loop body routable by a fault edge on
the loop. That is precisely the one-edge switch #3863 exists to prevent. A record fix must
not move accept/reject behaviour, so the error is rethrown with its identity, message and
guard-refusal marking intact.

What changes is only the record, and only for iterations that actually ran:

summary = { selected: 5, acted: 5, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure),
flag@0 ok, notify@0 ok, flag@1 ok, notify@1 ok, flag@2 ok, notify@2 FAILURE ]

Rows 4 and 5 were never entered and no step claims they were. The per-node breakdown now
lists the body nodes that ran (flag runs 3 acted 3; notify runs 3 failures 1 acted 2)
instead of only the container. Every folded step carries a parentNodeId set by
runRegion's tagger, so the ADR-0044 runaway guard — which counts only top-level visits —
cannot see them either.

Nesting is handled rather than assumed: as a failure unwinds through nested containers each
one re-brands the same error with its own accumulator, and because an outer container's
sink has already absorbed the inner one's steps, the last write is a superset of every
earlier one and each step object still reaches the log exactly once. The brand is writable
for this reason — a non-writable one would make the second container to unwind throw a
TypeError, turning a nested-loop failure into an engine crash. There is a test for it.

Verification

All readings below are at dbc1d661a, the branch head.

The directional assertion is the pin.expectNeverUnderReports asserts the reported
acted is never lower than the writes the store actually holds, rather than that it
equals 3 or 5 — the direction survives a refactor, the literal does not. Under ablation it
reads:

reported acted=0 is LOWER than the 5 writes that actually happened —
an operator reading this concludes "nothing happened, safe to re-run"

Reverse control. A sweep whose body's first node fails writes nothing and must still
report acted: 0, because there that is the honest answer. It does, and it stays green
under the ablation — it is insensitive to this fix by construction, which is exactly what
stops the repair degenerating into copying selected into acted. A second reverse case
pins the boundary: failing on the first element after one write reports acted: 1, not 0
and not selected.

Controls, declared as controls and not offered as ablation evidence. An all-succeeding
sweep and the try_catch-contained path (#13681's face) were captured before and after the
change and are byte-identical — the measured lines diff clean, sha 6b1579d5... and
7bf71866... respectively. The failing run's propagation fields (success, error,
run.status) are byte-identical too.

Ablation. Direction predicted first: removing the engine-side fold should redden the
six tests that read the recovered record and leave the four that do not. Measured exactly
that — 6 failed, 4 passed, and the four greens were the predicted ones. The mutation was
confirmed on disk before the run (deleted-text count 1 to 0, injected marker 0 to 1, blob
b5e4b3f5... to 19682f89...), never from an editor's exit code, and the restore was
proven by state afterwards (git diff HEAD empty, blob back to b5e4b3f5...), not by a
trap firing. No rebuild leg applies: the subject is reached through relative same-package
imports (../engine.js, ./loop-node.js), so vitest resolves source, never dist
confirmed empirically, since editing only service-automation/src without rebuilding that
package changed the measured result.

Suites and gates.

  • pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=295
    files, 1133 tests, all pass
    .
  • Gate family re-derived from the actual diff with
    node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read
    whole: 30 path-derived plus 7 convention-triggered. 34 of 35 pass. The one exception
    is scripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET locally
    because it grades a tee'd turbo run test log that only CI produces; its own output says
    to record it as NOT MEASURED and that it "is not a red". Recorded as NOT MEASURED, not as
    a pass. Every exit code was captured before any pipe.
  • pnpm check:type-check-debt (the --re-measure ratchet) passes with the workspace
    closure built, so the ledgered count did not drift.
  • pnpm lint (eslint . --no-inline-config, whole repo) — exit 0, 64s. No narrowing.
  • pnpm check:nul-bytes — clean over 7741 files; the diff was also scanned directly for
    raw control bytes.
  • tsc --listFiles lists all four touched sources, the new test file included, and the
    only errors reported are the three pre-existing ledgered TS2341 in
    nested-region-parity.test.ts, a file this PR does not touch.

Scope

Deliberately not addressed here, and each remains open: #13681's ruled B-branch is the
contained path's docs and lint visibility, whereas this is the uncontained path's
summary under-reporting; the resume ordering, forgetSuspendedRun and traverseNext are
#13937's decision surface in the same file; and restoreConsumedSuspension is untouched.
No file under packages/spec/ is modified, so the declared shapes of AutomationResult,
the run summary and steps are unchanged — StepLogEntry is engine-local.

One adjacent finding went to its own card, #14184, and remains open: try_catch with no
catch region returns a failing result and deliberately withholds its childSteps, citing
the same engine asymmetry. This PR closes that asymmetry for the throw path only and leaves
the engine's returned-failure branch alone, since no executor returns childSteps there
today and a fold for zero producers would be speculative — so a try_catch with no handler
still loses its try-region record.

Generated by Claude Code


Generated by Claude Code

…the run log
A `loop` that died mid-sweep discarded its body's `childSteps` wholesale: the
engine splices them into the run's step list only after a SUCCESSFUL node
result, and a loop whose body throws never produces a result at all. A run that
genuinely performed 3 flag writes and 2 notifies before dying reported
`{selected: 5, acted: 0}` and kept no step record of writes already committed.
The danger is the direction, not the arithmetic: `acted: 0` on a failed sweep
reads as "nothing happened, safe to re-run", which for a non-idempotent body
invites double-execution.
A dying container now carries its completed body steps out on the thrown error
-- a non-enumerable symbol brand, the idiom #3863 already uses to mark
un-routable guard refusals on this same throw path -- and the engine's catch
path folds them into the run log where the success path splices `childSteps`.
The error is rethrown unchanged, so failure propagation, `fault`-edge routing,
`$error` and the run's reported error are all untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from bfe2575a0eca030611b87310217e96d824541ca8 — the merge of head dbc1d661a112f353bb9e05132f5bcb00e144821a into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bfe2575a0eca030611b87310217e96d824541ca8 && git checkout bfe2575a0eca030611b87310217e96d824541ca8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 dbc1d661a112f353bb9e05132f5bcb00e144821a && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff dbc1d661a112f353bb9e05132f5bcb00e144821a
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
Collaborator

PM 复核:通过 —— 落地前检已过,CI 全绿即转 ready + auto-merge

落地前检按最终 5 条路径当场重跑:0 of 5 path(s) hit the register ⇒ 非受管面。
Clause-②: no 采纳,并已独立复核:5 个文件无一在 packages/spec/**;partial-steps.ts 虽有导出,但 src/index.ts不在 diff 里,本席另查桶文件确认它不引用该模块 ⇒ 模块内部,未扩大发布面

⭐ 本轮最值钱的不是修复,是你证伪了两条候选修法

派发令说「三条路都未考察,自己量、自己选、写明为什么是这条」。你真的量了,而且量出了反直觉的结果:

  1. 「splice childSteps before result grading」—— 卡自己的第一条建议 —— 你孤立实现后实测它是 no-op。 理由干净利落:loop 是而不是返回,引擎走 catch 臂,根本没有 result 对象可评级,摘要照旧 acted: 0。⇒ 一个只读卡片就动手的实现会照着这条做,然后得到一个看起来合理、实际什么都没修的 diff。
  2. summary-side 修复 —— 你判它「计数是缺失,不是欠聚合」,而诚实版本需要给摘要加字段 ⇒ 那是条款②路径肢。⇒ 你不是绕开了停手条件,是选了一条不触发它的正确路
  3. 「flush per-iteration」 —— 需要 run 的 step 数组,而 NodeExecutor.execute 拿不到。

⭐ 而被否掉的第四条,理由是语义安全,不是风格

The alternative shape — having loop swallow the failure and return { success: false } — was rejected for precisely this reason: it would have made a guard refusal raised inside a loop body routable by a fault edge on the loop, the one-edge switch #3863 exists to prevent.

⇒ 一个「记录级」修复若顺手把 loop 改成返回失败,就会把不可路由的守卫拒绝变成可路由的。这是行为面的改变,而本卡是记录面的卡。看出这一点需要读 #3863 为什么存在,不是读 loop 的代码就能得到。

证据形状,逐条对上

  • 方向性 pin 就是卡本身:expectNeverUnderReports 断言 reported acted永不低于库里实际写数,而不是「现在等于 5」。ablation 下它打印的失败信息本身就是这张卡:"reported acted=0 is LOWER than the 5 writes that actually happened — an operator reading this concludes 'nothing happened, safe to re-run'"。⇒ 断言的措辞把危害方向写进了失败输出。
  • 反向控制做到了它该做的:首节点即失败、零写入 ⇒ 仍报 acted: 0(那里 0 才是诚实答案),且在 ablation 下保持绿 —— 按构造对本修复不敏感。⇒ 这正是那条防「把 selected 抄给 acted」的闸;你还补了第二个反向例(首元素写了一次才失败 ⇒ 报 1,不是 0 也不是 3)。
  • 控制项前后逐字节比对:全成功 sweep 与 try_catch 包住的路径(A loop node aborts the entire flow run when one iteration's node fails — a single bad row kills a whole scheduled sweep, and there is no per-iteration containment to opt into #13681 的面)取了 sha 对比(6b1579d58b3b9404 / 7bf718661e8be578),失败运行的传播字段(successerrorrun.status)也逐字节相同。
  • 嵌套不重复计数有专测:loop 套 loop,断言 acted恰好等于真实写数(折叠两次会把它顶到真实值之上,等式能抓到),并断言 new Set(steps).size === steps.length
  • 无 rebuild 腿是被证明的而非假设:只改 service-automation/src 不重建就改变了测得结果(acted 0 → 5)⇒ vitest 走源码,dist 不在解析路径上。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿未当红。

⚠️ 一处设计选择值得记下来,因为它是「差点变成崩溃」的那种

brand 被刻意设为可写,注释说明了原因:不可写的 brand 会让第二个展开的容器抛 TypeError,把一次嵌套 loop 失败变成引擎崩溃。⇒ 一个看起来更"严格"的选择在这里是错的,而你把理由写在了代码旁边,不是留给下一个人重新踩。

衍生卡

#14184 已定级路由:bug · p3 · domain:services · pm:queue —— try_catchcatch 区时在返回失败路径上丢弃 try 区 step 记录,是同一处不对称的另一半。⭐ 你刻意只修抛出路径、把返回失败那半留着不动(理由:今天没有任何 executor 在那条路上返回 childSteps,折叠会是投机性的)—— 这个边界画得对,记在案。


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(service-automation): keep a dying loop's completed body steps in the run log - #14185

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure
Sep 1, 2026
Merged

fix(service-automation): keep a dying loop's completed body steps in the run log#14185
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13803

A loop that dies mid-sweep discarded its body's completed steps wholesale. The engine
splices a container's childSteps into the run's step list only after a successful
node result, and a loop whose body throws never produces a result at all — the throw
unwinds straight past the splice, taking the accumulated array with the stack frame.

Reproduced on this branch's base (682d03ba7), not from the card's 43e18f51 cite, with
the real AutomationEngine and the card's own five-row shape (row 3 has a null owner, so
notify fails):

flagged = ["c1","c2","c3"] notified = ["c1","c2"] -> 5 writes really happened
summary = { selected: 5, acted: 0, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure) ] -> no record of any of them

The direction is the defect, not the arithmetic. An operator reading acted: 0 on a
failed sweep reasonably concludes "nothing happened, safe to re-run" — but the writes did
happen. For a non-idempotent body (notifications, counters, external calls) that misread
invites double-execution, and for auditing the rows exist in the data with no step record
to explain them. The run summary is the platform's own honesty instrument (#4354), and on
this path it was wrong in the one direction that causes harm.

Why this fix and not the other two

The card named three unexamined candidates. All three were measured before choosing.

A summary-side repair — rejected on evidence.summarizeRun is a pure fold over the
flat step log, and on the failing path that log holds three entries: start, query, and
the loop's own failure step, which carries no metrics. The counts are not
under-aggregated, they are absent. A summary-side repair could therefore only invent
numbers it has no record of, or degrade to reporting the sweep as unmeasured — and the
honest version of the latter needs a field on the summary, which is
packages/spec/src/contracts/automation-service.ts and the clause-② path limb. Rejected
without writing code: the data has to be preserved, not reinterpreted.

Splice childSteps before result grading — measured, and it is a no-op here. This one
was implemented in isolation and run, because the reasoning is easy to get wrong. Adding
the childSteps splice to the engine's if (!result.success) branch changes nothing on
this path: the loop does not return a failing result, it throws, so the engine's
catch (execErr) arm runs instead and there is no result object to splice from. The
measured summary after that change was still { selected: 5, acted: 0 }, and the step log
was still three entries. The tell is visible in the log without the experiment: the
loop's step carries EXECUTION_ERROR, which only the catch arm emits. The mutation was
confirmed on disk by blob hash and marker count before the run, and reverted with
git diff HEAD proven empty afterwards.

Flush per-iteration — the right instinct, in the wrong place. For the loop to write
body steps into the run log as it goes, it needs the run's step array, and
NodeExecutor.execute(node, variables, context) does not receive it. Reaching it means
widening the executor contract for every executor to serve one container.

What shipped keeps the per-iteration instinct but uses the seam #7546 already built.
runRegion grew a partialSteps sink for exactly this — surfacing a failed region's
partial steps — and wired only try_catch to it, noting that "callers that do not pass a
sink (loop, parallel) are unaffected". So:

  1. loop now passes its childSteps accumulator as that sink, which captures the
    failing iteration's steps too (success returns them instead, so nothing is counted
    twice), and
  2. the dying container carries the accumulated array out on the thrown error as a
    non-enumerable symbol brand, which the engine's catch arm folds into the run log in the
    same position the success path splices childSteps — behind the container's own step,
    ahead of any fault handler's.

The brand is not a new idea in this package: #3863's markGuardRefusal already carries
"this failure is un-routable" out through this identical throw path the same way.
#7546 declined the exception channel for runRegion because it "would either change what
callers catch or require a bespoke error type" — neither cost lands here, since this is
the opposite direction (executor back to engine) and the error object is passed through
untouched rather than wrapped.

What it means under partial failure

The run still fails. Nothing about accept/reject behaviour moves — that was the deciding
constraint, and it is why the losing alternative lost. Having loop swallow the failure
and return { success: false, childSteps } would have made the engine's failure branch
work, but it would also rewrite the run's error text (Node 'notify' failed: ... becomes
Node 'each' failed: Node 'notify' failed: ...), change the container step's code from
EXECUTION_ERROR to NODE_FAILURE, set $error where it previously stayed unset, and —
decisively — make a guard refusal raised inside a loop body routable by a fault edge on
the loop. That is precisely the one-edge switch #3863 exists to prevent. A record fix must
not move accept/reject behaviour, so the error is rethrown with its identity, message and
guard-refusal marking intact.

What changes is only the record, and only for iterations that actually ran:

summary = { selected: 5, acted: 5, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure),
flag@0 ok, notify@0 ok, flag@1 ok, notify@1 ok, flag@2 ok, notify@2 FAILURE ]

Rows 4 and 5 were never entered and no step claims they were. The per-node breakdown now
lists the body nodes that ran (flag runs 3 acted 3; notify runs 3 failures 1 acted 2)
instead of only the container. Every folded step carries a parentNodeId set by
runRegion's tagger, so the ADR-0044 runaway guard — which counts only top-level visits —
cannot see them either.

Nesting is handled rather than assumed: as a failure unwinds through nested containers each
one re-brands the same error with its own accumulator, and because an outer container's
sink has already absorbed the inner one's steps, the last write is a superset of every
earlier one and each step object still reaches the log exactly once. The brand is writable
for this reason — a non-writable one would make the second container to unwind throw a
TypeError, turning a nested-loop failure into an engine crash. There is a test for it.

Verification

All readings below are at dbc1d661a, the branch head.

The directional assertion is the pin.expectNeverUnderReports asserts the reported
acted is never lower than the writes the store actually holds, rather than that it
equals 3 or 5 — the direction survives a refactor, the literal does not. Under ablation it
reads:

reported acted=0 is LOWER than the 5 writes that actually happened —
an operator reading this concludes "nothing happened, safe to re-run"

Reverse control. A sweep whose body's first node fails writes nothing and must still
report acted: 0, because there that is the honest answer. It does, and it stays green
under the ablation — it is insensitive to this fix by construction, which is exactly what
stops the repair degenerating into copying selected into acted. A second reverse case
pins the boundary: failing on the first element after one write reports acted: 1, not 0
and not selected.

Controls, declared as controls and not offered as ablation evidence. An all-succeeding
sweep and the try_catch-contained path (#13681's face) were captured before and after the
change and are byte-identical — the measured lines diff clean, sha 6b1579d5... and
7bf71866... respectively. The failing run's propagation fields (success, error,
run.status) are byte-identical too.

Ablation. Direction predicted first: removing the engine-side fold should redden the
six tests that read the recovered record and leave the four that do not. Measured exactly
that — 6 failed, 4 passed, and the four greens were the predicted ones. The mutation was
confirmed on disk before the run (deleted-text count 1 to 0, injected marker 0 to 1, blob
b5e4b3f5... to 19682f89...), never from an editor's exit code, and the restore was
proven by state afterwards (git diff HEAD empty, blob back to b5e4b3f5...), not by a
trap firing. No rebuild leg applies: the subject is reached through relative same-package
imports (../engine.js, ./loop-node.js), so vitest resolves source, never dist
confirmed empirically, since editing only service-automation/src without rebuilding that
package changed the measured result.

Suites and gates.

  • pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=295
    files, 1133 tests, all pass
    .
  • Gate family re-derived from the actual diff with
    node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read
    whole: 30 path-derived plus 7 convention-triggered. 34 of 35 pass. The one exception
    is scripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET locally
    because it grades a tee'd turbo run test log that only CI produces; its own output says
    to record it as NOT MEASURED and that it "is not a red". Recorded as NOT MEASURED, not as
    a pass. Every exit code was captured before any pipe.
  • pnpm check:type-check-debt (the --re-measure ratchet) passes with the workspace
    closure built, so the ledgered count did not drift.
  • pnpm lint (eslint . --no-inline-config, whole repo) — exit 0, 64s. No narrowing.
  • pnpm check:nul-bytes — clean over 7741 files; the diff was also scanned directly for
    raw control bytes.
  • tsc --listFiles lists all four touched sources, the new test file included, and the
    only errors reported are the three pre-existing ledgered TS2341 in
    nested-region-parity.test.ts, a file this PR does not touch.

Scope

Deliberately not addressed here, and each remains open: #13681's ruled B-branch is the
contained path's docs and lint visibility, whereas this is the uncontained path's
summary under-reporting; the resume ordering, forgetSuspendedRun and traverseNext are
#13937's decision surface in the same file; and restoreConsumedSuspension is untouched.
No file under packages/spec/ is modified, so the declared shapes of AutomationResult,
the run summary and steps are unchanged — StepLogEntry is engine-local.

One adjacent finding went to its own card, #14184, and remains open: try_catch with no
catch region returns a failing result and deliberately withholds its childSteps, citing
the same engine asymmetry. This PR closes that asymmetry for the throw path only and leaves
the engine's returned-failure branch alone, since no executor returns childSteps there
today and a fold for zero producers would be speculative — so a try_catch with no handler
still loses its try-region record.

Generated by Claude Code


Generated by Claude Code

…the run log
A `loop` that died mid-sweep discarded its body's `childSteps` wholesale: the
engine splices them into the run's step list only after a SUCCESSFUL node
result, and a loop whose body throws never produces a result at all. A run that
genuinely performed 3 flag writes and 2 notifies before dying reported
`{selected: 5, acted: 0}` and kept no step record of writes already committed.
The danger is the direction, not the arithmetic: `acted: 0` on a failed sweep
reads as "nothing happened, safe to re-run", which for a non-idempotent body
invites double-execution.
A dying container now carries its completed body steps out on the thrown error
-- a non-enumerable symbol brand, the idiom #3863 already uses to mark
un-routable guard refusals on this same throw path -- and the engine's catch
path folds them into the run log where the success path splices `childSteps`.
The error is rethrown unchanged, so failure propagation, `fault`-edge routing,
`$error` and the run's reported error are all untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from bfe2575a0eca030611b87310217e96d824541ca8 — the merge of head dbc1d661a112f353bb9e05132f5bcb00e144821a into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bfe2575a0eca030611b87310217e96d824541ca8 && git checkout bfe2575a0eca030611b87310217e96d824541ca8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 dbc1d661a112f353bb9e05132f5bcb00e144821a && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff dbc1d661a112f353bb9e05132f5bcb00e144821a
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
Collaborator

PM 复核:通过 —— 落地前检已过,CI 全绿即转 ready + auto-merge

落地前检按最终 5 条路径当场重跑:0 of 5 path(s) hit the register ⇒ 非受管面。
Clause-②: no 采纳,并已独立复核:5 个文件无一在 packages/spec/**;partial-steps.ts 虽有导出,但 src/index.ts不在 diff 里,本席另查桶文件确认它不引用该模块 ⇒ 模块内部,未扩大发布面

⭐ 本轮最值钱的不是修复,是你证伪了两条候选修法

派发令说「三条路都未考察,自己量、自己选、写明为什么是这条」。你真的量了,而且量出了反直觉的结果:

  1. 「splice childSteps before result grading」—— 卡自己的第一条建议 —— 你孤立实现后实测它是 no-op。 理由干净利落:loop 是而不是返回,引擎走 catch 臂,根本没有 result 对象可评级,摘要照旧 acted: 0。⇒ 一个只读卡片就动手的实现会照着这条做,然后得到一个看起来合理、实际什么都没修的 diff。
  2. summary-side 修复 —— 你判它「计数是缺失,不是欠聚合」,而诚实版本需要给摘要加字段 ⇒ 那是条款②路径肢。⇒ 你不是绕开了停手条件,是选了一条不触发它的正确路
  3. 「flush per-iteration」 —— 需要 run 的 step 数组,而 NodeExecutor.execute 拿不到。

⭐ 而被否掉的第四条,理由是语义安全,不是风格

The alternative shape — having loop swallow the failure and return { success: false } — was rejected for precisely this reason: it would have made a guard refusal raised inside a loop body routable by a fault edge on the loop, the one-edge switch #3863 exists to prevent.

⇒ 一个「记录级」修复若顺手把 loop 改成返回失败,就会把不可路由的守卫拒绝变成可路由的。这是行为面的改变,而本卡是记录面的卡。看出这一点需要读 #3863 为什么存在,不是读 loop 的代码就能得到。

证据形状,逐条对上

  • 方向性 pin 就是卡本身:expectNeverUnderReports 断言 reported acted永不低于库里实际写数,而不是「现在等于 5」。ablation 下它打印的失败信息本身就是这张卡:"reported acted=0 is LOWER than the 5 writes that actually happened — an operator reading this concludes 'nothing happened, safe to re-run'"。⇒ 断言的措辞把危害方向写进了失败输出。
  • 反向控制做到了它该做的:首节点即失败、零写入 ⇒ 仍报 acted: 0(那里 0 才是诚实答案),且在 ablation 下保持绿 —— 按构造对本修复不敏感。⇒ 这正是那条防「把 selected 抄给 acted」的闸;你还补了第二个反向例(首元素写了一次才失败 ⇒ 报 1,不是 0 也不是 3)。
  • 控制项前后逐字节比对:全成功 sweep 与 try_catch 包住的路径(A loop node aborts the entire flow run when one iteration's node fails — a single bad row kills a whole scheduled sweep, and there is no per-iteration containment to opt into #13681 的面)取了 sha 对比(6b1579d58b3b9404 / 7bf718661e8be578),失败运行的传播字段(successerrorrun.status)也逐字节相同。
  • 嵌套不重复计数有专测:loop 套 loop,断言 acted恰好等于真实写数(折叠两次会把它顶到真实值之上,等式能抓到),并断言 new Set(steps).size === steps.length
  • 无 rebuild 腿是被证明的而非假设:只改 service-automation/src 不重建就改变了测得结果(acted 0 → 5)⇒ vitest 走源码,dist 不在解析路径上。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿未当红。

⚠️ 一处设计选择值得记下来,因为它是「差点变成崩溃」的那种

brand 被刻意设为可写,注释说明了原因:不可写的 brand 会让第二个展开的容器抛 TypeError,把一次嵌套 loop 失败变成引擎崩溃。⇒ 一个看起来更"严格"的选择在这里是错的,而你把理由写在了代码旁边,不是留给下一个人重新踩。

衍生卡

#14184 已定级路由:bug · p3 · domain:services · pm:queue —— try_catchcatch 区时在返回失败路径上丢弃 try 区 step 记录,是同一处不对称的另一半。⭐ 你刻意只修抛出路径、把返回失败那半留着不动(理由:今天没有任何 executor 在那条路上返回 childSteps,折叠会是投机性的)—— 这个边界画得对,记在案。


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(service-automation): keep a dying loop's completed body steps in the run log - #14185

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure
Sep 1, 2026
Merged

fix(service-automation): keep a dying loop's completed body steps in the run log#14185
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13803

A loop that dies mid-sweep discarded its body's completed steps wholesale. The engine
splices a container's childSteps into the run's step list only after a successful
node result, and a loop whose body throws never produces a result at all — the throw
unwinds straight past the splice, taking the accumulated array with the stack frame.

Reproduced on this branch's base (682d03ba7), not from the card's 43e18f51 cite, with
the real AutomationEngine and the card's own five-row shape (row 3 has a null owner, so
notify fails):

flagged = ["c1","c2","c3"] notified = ["c1","c2"] -> 5 writes really happened
summary = { selected: 5, acted: 0, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure) ] -> no record of any of them

The direction is the defect, not the arithmetic. An operator reading acted: 0 on a
failed sweep reasonably concludes "nothing happened, safe to re-run" — but the writes did
happen. For a non-idempotent body (notifications, counters, external calls) that misread
invites double-execution, and for auditing the rows exist in the data with no step record
to explain them. The run summary is the platform's own honesty instrument (#4354), and on
this path it was wrong in the one direction that causes harm.

Why this fix and not the other two

The card named three unexamined candidates. All three were measured before choosing.

A summary-side repair — rejected on evidence.summarizeRun is a pure fold over the
flat step log, and on the failing path that log holds three entries: start, query, and
the loop's own failure step, which carries no metrics. The counts are not
under-aggregated, they are absent. A summary-side repair could therefore only invent
numbers it has no record of, or degrade to reporting the sweep as unmeasured — and the
honest version of the latter needs a field on the summary, which is
packages/spec/src/contracts/automation-service.ts and the clause-② path limb. Rejected
without writing code: the data has to be preserved, not reinterpreted.

Splice childSteps before result grading — measured, and it is a no-op here. This one
was implemented in isolation and run, because the reasoning is easy to get wrong. Adding
the childSteps splice to the engine's if (!result.success) branch changes nothing on
this path: the loop does not return a failing result, it throws, so the engine's
catch (execErr) arm runs instead and there is no result object to splice from. The
measured summary after that change was still { selected: 5, acted: 0 }, and the step log
was still three entries. The tell is visible in the log without the experiment: the
loop's step carries EXECUTION_ERROR, which only the catch arm emits. The mutation was
confirmed on disk by blob hash and marker count before the run, and reverted with
git diff HEAD proven empty afterwards.

Flush per-iteration — the right instinct, in the wrong place. For the loop to write
body steps into the run log as it goes, it needs the run's step array, and
NodeExecutor.execute(node, variables, context) does not receive it. Reaching it means
widening the executor contract for every executor to serve one container.

What shipped keeps the per-iteration instinct but uses the seam #7546 already built.
runRegion grew a partialSteps sink for exactly this — surfacing a failed region's
partial steps — and wired only try_catch to it, noting that "callers that do not pass a
sink (loop, parallel) are unaffected". So:

  1. loop now passes its childSteps accumulator as that sink, which captures the
    failing iteration's steps too (success returns them instead, so nothing is counted
    twice), and
  2. the dying container carries the accumulated array out on the thrown error as a
    non-enumerable symbol brand, which the engine's catch arm folds into the run log in the
    same position the success path splices childSteps — behind the container's own step,
    ahead of any fault handler's.

The brand is not a new idea in this package: #3863's markGuardRefusal already carries
"this failure is un-routable" out through this identical throw path the same way.
#7546 declined the exception channel for runRegion because it "would either change what
callers catch or require a bespoke error type" — neither cost lands here, since this is
the opposite direction (executor back to engine) and the error object is passed through
untouched rather than wrapped.

What it means under partial failure

The run still fails. Nothing about accept/reject behaviour moves — that was the deciding
constraint, and it is why the losing alternative lost. Having loop swallow the failure
and return { success: false, childSteps } would have made the engine's failure branch
work, but it would also rewrite the run's error text (Node 'notify' failed: ... becomes
Node 'each' failed: Node 'notify' failed: ...), change the container step's code from
EXECUTION_ERROR to NODE_FAILURE, set $error where it previously stayed unset, and —
decisively — make a guard refusal raised inside a loop body routable by a fault edge on
the loop. That is precisely the one-edge switch #3863 exists to prevent. A record fix must
not move accept/reject behaviour, so the error is rethrown with its identity, message and
guard-refusal marking intact.

What changes is only the record, and only for iterations that actually ran:

summary = { selected: 5, acted: 5, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure),
flag@0 ok, notify@0 ok, flag@1 ok, notify@1 ok, flag@2 ok, notify@2 FAILURE ]

Rows 4 and 5 were never entered and no step claims they were. The per-node breakdown now
lists the body nodes that ran (flag runs 3 acted 3; notify runs 3 failures 1 acted 2)
instead of only the container. Every folded step carries a parentNodeId set by
runRegion's tagger, so the ADR-0044 runaway guard — which counts only top-level visits —
cannot see them either.

Nesting is handled rather than assumed: as a failure unwinds through nested containers each
one re-brands the same error with its own accumulator, and because an outer container's
sink has already absorbed the inner one's steps, the last write is a superset of every
earlier one and each step object still reaches the log exactly once. The brand is writable
for this reason — a non-writable one would make the second container to unwind throw a
TypeError, turning a nested-loop failure into an engine crash. There is a test for it.

Verification

All readings below are at dbc1d661a, the branch head.

The directional assertion is the pin.expectNeverUnderReports asserts the reported
acted is never lower than the writes the store actually holds, rather than that it
equals 3 or 5 — the direction survives a refactor, the literal does not. Under ablation it
reads:

reported acted=0 is LOWER than the 5 writes that actually happened —
an operator reading this concludes "nothing happened, safe to re-run"

Reverse control. A sweep whose body's first node fails writes nothing and must still
report acted: 0, because there that is the honest answer. It does, and it stays green
under the ablation — it is insensitive to this fix by construction, which is exactly what
stops the repair degenerating into copying selected into acted. A second reverse case
pins the boundary: failing on the first element after one write reports acted: 1, not 0
and not selected.

Controls, declared as controls and not offered as ablation evidence. An all-succeeding
sweep and the try_catch-contained path (#13681's face) were captured before and after the
change and are byte-identical — the measured lines diff clean, sha 6b1579d5... and
7bf71866... respectively. The failing run's propagation fields (success, error,
run.status) are byte-identical too.

Ablation. Direction predicted first: removing the engine-side fold should redden the
six tests that read the recovered record and leave the four that do not. Measured exactly
that — 6 failed, 4 passed, and the four greens were the predicted ones. The mutation was
confirmed on disk before the run (deleted-text count 1 to 0, injected marker 0 to 1, blob
b5e4b3f5... to 19682f89...), never from an editor's exit code, and the restore was
proven by state afterwards (git diff HEAD empty, blob back to b5e4b3f5...), not by a
trap firing. No rebuild leg applies: the subject is reached through relative same-package
imports (../engine.js, ./loop-node.js), so vitest resolves source, never dist
confirmed empirically, since editing only service-automation/src without rebuilding that
package changed the measured result.

Suites and gates.

  • pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=295
    files, 1133 tests, all pass
    .
  • Gate family re-derived from the actual diff with
    node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read
    whole: 30 path-derived plus 7 convention-triggered. 34 of 35 pass. The one exception
    is scripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET locally
    because it grades a tee'd turbo run test log that only CI produces; its own output says
    to record it as NOT MEASURED and that it "is not a red". Recorded as NOT MEASURED, not as
    a pass. Every exit code was captured before any pipe.
  • pnpm check:type-check-debt (the --re-measure ratchet) passes with the workspace
    closure built, so the ledgered count did not drift.
  • pnpm lint (eslint . --no-inline-config, whole repo) — exit 0, 64s. No narrowing.
  • pnpm check:nul-bytes — clean over 7741 files; the diff was also scanned directly for
    raw control bytes.
  • tsc --listFiles lists all four touched sources, the new test file included, and the
    only errors reported are the three pre-existing ledgered TS2341 in
    nested-region-parity.test.ts, a file this PR does not touch.

Scope

Deliberately not addressed here, and each remains open: #13681's ruled B-branch is the
contained path's docs and lint visibility, whereas this is the uncontained path's
summary under-reporting; the resume ordering, forgetSuspendedRun and traverseNext are
#13937's decision surface in the same file; and restoreConsumedSuspension is untouched.
No file under packages/spec/ is modified, so the declared shapes of AutomationResult,
the run summary and steps are unchanged — StepLogEntry is engine-local.

One adjacent finding went to its own card, #14184, and remains open: try_catch with no
catch region returns a failing result and deliberately withholds its childSteps, citing
the same engine asymmetry. This PR closes that asymmetry for the throw path only and leaves
the engine's returned-failure branch alone, since no executor returns childSteps there
today and a fold for zero producers would be speculative — so a try_catch with no handler
still loses its try-region record.

Generated by Claude Code


Generated by Claude Code

…the run log
A `loop` that died mid-sweep discarded its body's `childSteps` wholesale: the
engine splices them into the run's step list only after a SUCCESSFUL node
result, and a loop whose body throws never produces a result at all. A run that
genuinely performed 3 flag writes and 2 notifies before dying reported
`{selected: 5, acted: 0}` and kept no step record of writes already committed.
The danger is the direction, not the arithmetic: `acted: 0` on a failed sweep
reads as "nothing happened, safe to re-run", which for a non-idempotent body
invites double-execution.
A dying container now carries its completed body steps out on the thrown error
-- a non-enumerable symbol brand, the idiom #3863 already uses to mark
un-routable guard refusals on this same throw path -- and the engine's catch
path folds them into the run log where the success path splices `childSteps`.
The error is rethrown unchanged, so failure propagation, `fault`-edge routing,
`$error` and the run's reported error are all untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from bfe2575a0eca030611b87310217e96d824541ca8 — the merge of head dbc1d661a112f353bb9e05132f5bcb00e144821a into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bfe2575a0eca030611b87310217e96d824541ca8 && git checkout bfe2575a0eca030611b87310217e96d824541ca8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 dbc1d661a112f353bb9e05132f5bcb00e144821a && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff dbc1d661a112f353bb9e05132f5bcb00e144821a
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
Collaborator

PM 复核:通过 —— 落地前检已过,CI 全绿即转 ready + auto-merge

落地前检按最终 5 条路径当场重跑:0 of 5 path(s) hit the register ⇒ 非受管面。
Clause-②: no 采纳,并已独立复核:5 个文件无一在 packages/spec/**;partial-steps.ts 虽有导出,但 src/index.ts不在 diff 里,本席另查桶文件确认它不引用该模块 ⇒ 模块内部,未扩大发布面

⭐ 本轮最值钱的不是修复,是你证伪了两条候选修法

派发令说「三条路都未考察,自己量、自己选、写明为什么是这条」。你真的量了,而且量出了反直觉的结果:

  1. 「splice childSteps before result grading」—— 卡自己的第一条建议 —— 你孤立实现后实测它是 no-op。 理由干净利落:loop 是而不是返回,引擎走 catch 臂,根本没有 result 对象可评级,摘要照旧 acted: 0。⇒ 一个只读卡片就动手的实现会照着这条做,然后得到一个看起来合理、实际什么都没修的 diff。
  2. summary-side 修复 —— 你判它「计数是缺失,不是欠聚合」,而诚实版本需要给摘要加字段 ⇒ 那是条款②路径肢。⇒ 你不是绕开了停手条件,是选了一条不触发它的正确路
  3. 「flush per-iteration」 —— 需要 run 的 step 数组,而 NodeExecutor.execute 拿不到。

⭐ 而被否掉的第四条,理由是语义安全,不是风格

The alternative shape — having loop swallow the failure and return { success: false } — was rejected for precisely this reason: it would have made a guard refusal raised inside a loop body routable by a fault edge on the loop, the one-edge switch #3863 exists to prevent.

⇒ 一个「记录级」修复若顺手把 loop 改成返回失败,就会把不可路由的守卫拒绝变成可路由的。这是行为面的改变,而本卡是记录面的卡。看出这一点需要读 #3863 为什么存在,不是读 loop 的代码就能得到。

证据形状,逐条对上

  • 方向性 pin 就是卡本身:expectNeverUnderReports 断言 reported acted永不低于库里实际写数,而不是「现在等于 5」。ablation 下它打印的失败信息本身就是这张卡:"reported acted=0 is LOWER than the 5 writes that actually happened — an operator reading this concludes 'nothing happened, safe to re-run'"。⇒ 断言的措辞把危害方向写进了失败输出。
  • 反向控制做到了它该做的:首节点即失败、零写入 ⇒ 仍报 acted: 0(那里 0 才是诚实答案),且在 ablation 下保持绿 —— 按构造对本修复不敏感。⇒ 这正是那条防「把 selected 抄给 acted」的闸;你还补了第二个反向例(首元素写了一次才失败 ⇒ 报 1,不是 0 也不是 3)。
  • 控制项前后逐字节比对:全成功 sweep 与 try_catch 包住的路径(A loop node aborts the entire flow run when one iteration's node fails — a single bad row kills a whole scheduled sweep, and there is no per-iteration containment to opt into #13681 的面)取了 sha 对比(6b1579d58b3b9404 / 7bf718661e8be578),失败运行的传播字段(successerrorrun.status)也逐字节相同。
  • 嵌套不重复计数有专测:loop 套 loop,断言 acted恰好等于真实写数(折叠两次会把它顶到真实值之上,等式能抓到),并断言 new Set(steps).size === steps.length
  • 无 rebuild 腿是被证明的而非假设:只改 service-automation/src 不重建就改变了测得结果(acted 0 → 5)⇒ vitest 走源码,dist 不在解析路径上。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿未当红。

⚠️ 一处设计选择值得记下来,因为它是「差点变成崩溃」的那种

brand 被刻意设为可写,注释说明了原因:不可写的 brand 会让第二个展开的容器抛 TypeError,把一次嵌套 loop 失败变成引擎崩溃。⇒ 一个看起来更"严格"的选择在这里是错的,而你把理由写在了代码旁边,不是留给下一个人重新踩。

衍生卡

#14184 已定级路由:bug · p3 · domain:services · pm:queue —— try_catchcatch 区时在返回失败路径上丢弃 try 区 step 记录,是同一处不对称的另一半。⭐ 你刻意只修抛出路径、把返回失败那半留着不动(理由:今天没有任何 executor 在那条路上返回 childSteps,折叠会是投机性的)—— 这个边界画得对,记在案。


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(service-automation): keep a dying loop's completed body steps in the run log - #14185

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure
Sep 1, 2026
Merged

fix(service-automation): keep a dying loop's completed body steps in the run log#14185
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13803

A loop that dies mid-sweep discarded its body's completed steps wholesale. The engine
splices a container's childSteps into the run's step list only after a successful
node result, and a loop whose body throws never produces a result at all — the throw
unwinds straight past the splice, taking the accumulated array with the stack frame.

Reproduced on this branch's base (682d03ba7), not from the card's 43e18f51 cite, with
the real AutomationEngine and the card's own five-row shape (row 3 has a null owner, so
notify fails):

flagged = ["c1","c2","c3"] notified = ["c1","c2"] -> 5 writes really happened
summary = { selected: 5, acted: 0, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure) ] -> no record of any of them

The direction is the defect, not the arithmetic. An operator reading acted: 0 on a
failed sweep reasonably concludes "nothing happened, safe to re-run" — but the writes did
happen. For a non-idempotent body (notifications, counters, external calls) that misread
invites double-execution, and for auditing the rows exist in the data with no step record
to explain them. The run summary is the platform's own honesty instrument (#4354), and on
this path it was wrong in the one direction that causes harm.

Why this fix and not the other two

The card named three unexamined candidates. All three were measured before choosing.

A summary-side repair — rejected on evidence.summarizeRun is a pure fold over the
flat step log, and on the failing path that log holds three entries: start, query, and
the loop's own failure step, which carries no metrics. The counts are not
under-aggregated, they are absent. A summary-side repair could therefore only invent
numbers it has no record of, or degrade to reporting the sweep as unmeasured — and the
honest version of the latter needs a field on the summary, which is
packages/spec/src/contracts/automation-service.ts and the clause-② path limb. Rejected
without writing code: the data has to be preserved, not reinterpreted.

Splice childSteps before result grading — measured, and it is a no-op here. This one
was implemented in isolation and run, because the reasoning is easy to get wrong. Adding
the childSteps splice to the engine's if (!result.success) branch changes nothing on
this path: the loop does not return a failing result, it throws, so the engine's
catch (execErr) arm runs instead and there is no result object to splice from. The
measured summary after that change was still { selected: 5, acted: 0 }, and the step log
was still three entries. The tell is visible in the log without the experiment: the
loop's step carries EXECUTION_ERROR, which only the catch arm emits. The mutation was
confirmed on disk by blob hash and marker count before the run, and reverted with
git diff HEAD proven empty afterwards.

Flush per-iteration — the right instinct, in the wrong place. For the loop to write
body steps into the run log as it goes, it needs the run's step array, and
NodeExecutor.execute(node, variables, context) does not receive it. Reaching it means
widening the executor contract for every executor to serve one container.

What shipped keeps the per-iteration instinct but uses the seam #7546 already built.
runRegion grew a partialSteps sink for exactly this — surfacing a failed region's
partial steps — and wired only try_catch to it, noting that "callers that do not pass a
sink (loop, parallel) are unaffected". So:

  1. loop now passes its childSteps accumulator as that sink, which captures the
    failing iteration's steps too (success returns them instead, so nothing is counted
    twice), and
  2. the dying container carries the accumulated array out on the thrown error as a
    non-enumerable symbol brand, which the engine's catch arm folds into the run log in the
    same position the success path splices childSteps — behind the container's own step,
    ahead of any fault handler's.

The brand is not a new idea in this package: #3863's markGuardRefusal already carries
"this failure is un-routable" out through this identical throw path the same way.
#7546 declined the exception channel for runRegion because it "would either change what
callers catch or require a bespoke error type" — neither cost lands here, since this is
the opposite direction (executor back to engine) and the error object is passed through
untouched rather than wrapped.

What it means under partial failure

The run still fails. Nothing about accept/reject behaviour moves — that was the deciding
constraint, and it is why the losing alternative lost. Having loop swallow the failure
and return { success: false, childSteps } would have made the engine's failure branch
work, but it would also rewrite the run's error text (Node 'notify' failed: ... becomes
Node 'each' failed: Node 'notify' failed: ...), change the container step's code from
EXECUTION_ERROR to NODE_FAILURE, set $error where it previously stayed unset, and —
decisively — make a guard refusal raised inside a loop body routable by a fault edge on
the loop. That is precisely the one-edge switch #3863 exists to prevent. A record fix must
not move accept/reject behaviour, so the error is rethrown with its identity, message and
guard-refusal marking intact.

What changes is only the record, and only for iterations that actually ran:

summary = { selected: 5, acted: 5, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure),
flag@0 ok, notify@0 ok, flag@1 ok, notify@1 ok, flag@2 ok, notify@2 FAILURE ]

Rows 4 and 5 were never entered and no step claims they were. The per-node breakdown now
lists the body nodes that ran (flag runs 3 acted 3; notify runs 3 failures 1 acted 2)
instead of only the container. Every folded step carries a parentNodeId set by
runRegion's tagger, so the ADR-0044 runaway guard — which counts only top-level visits —
cannot see them either.

Nesting is handled rather than assumed: as a failure unwinds through nested containers each
one re-brands the same error with its own accumulator, and because an outer container's
sink has already absorbed the inner one's steps, the last write is a superset of every
earlier one and each step object still reaches the log exactly once. The brand is writable
for this reason — a non-writable one would make the second container to unwind throw a
TypeError, turning a nested-loop failure into an engine crash. There is a test for it.

Verification

All readings below are at dbc1d661a, the branch head.

The directional assertion is the pin.expectNeverUnderReports asserts the reported
acted is never lower than the writes the store actually holds, rather than that it
equals 3 or 5 — the direction survives a refactor, the literal does not. Under ablation it
reads:

reported acted=0 is LOWER than the 5 writes that actually happened —
an operator reading this concludes "nothing happened, safe to re-run"

Reverse control. A sweep whose body's first node fails writes nothing and must still
report acted: 0, because there that is the honest answer. It does, and it stays green
under the ablation — it is insensitive to this fix by construction, which is exactly what
stops the repair degenerating into copying selected into acted. A second reverse case
pins the boundary: failing on the first element after one write reports acted: 1, not 0
and not selected.

Controls, declared as controls and not offered as ablation evidence. An all-succeeding
sweep and the try_catch-contained path (#13681's face) were captured before and after the
change and are byte-identical — the measured lines diff clean, sha 6b1579d5... and
7bf71866... respectively. The failing run's propagation fields (success, error,
run.status) are byte-identical too.

Ablation. Direction predicted first: removing the engine-side fold should redden the
six tests that read the recovered record and leave the four that do not. Measured exactly
that — 6 failed, 4 passed, and the four greens were the predicted ones. The mutation was
confirmed on disk before the run (deleted-text count 1 to 0, injected marker 0 to 1, blob
b5e4b3f5... to 19682f89...), never from an editor's exit code, and the restore was
proven by state afterwards (git diff HEAD empty, blob back to b5e4b3f5...), not by a
trap firing. No rebuild leg applies: the subject is reached through relative same-package
imports (../engine.js, ./loop-node.js), so vitest resolves source, never dist
confirmed empirically, since editing only service-automation/src without rebuilding that
package changed the measured result.

Suites and gates.

  • pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=295
    files, 1133 tests, all pass
    .
  • Gate family re-derived from the actual diff with
    node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read
    whole: 30 path-derived plus 7 convention-triggered. 34 of 35 pass. The one exception
    is scripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET locally
    because it grades a tee'd turbo run test log that only CI produces; its own output says
    to record it as NOT MEASURED and that it "is not a red". Recorded as NOT MEASURED, not as
    a pass. Every exit code was captured before any pipe.
  • pnpm check:type-check-debt (the --re-measure ratchet) passes with the workspace
    closure built, so the ledgered count did not drift.
  • pnpm lint (eslint . --no-inline-config, whole repo) — exit 0, 64s. No narrowing.
  • pnpm check:nul-bytes — clean over 7741 files; the diff was also scanned directly for
    raw control bytes.
  • tsc --listFiles lists all four touched sources, the new test file included, and the
    only errors reported are the three pre-existing ledgered TS2341 in
    nested-region-parity.test.ts, a file this PR does not touch.

Scope

Deliberately not addressed here, and each remains open: #13681's ruled B-branch is the
contained path's docs and lint visibility, whereas this is the uncontained path's
summary under-reporting; the resume ordering, forgetSuspendedRun and traverseNext are
#13937's decision surface in the same file; and restoreConsumedSuspension is untouched.
No file under packages/spec/ is modified, so the declared shapes of AutomationResult,
the run summary and steps are unchanged — StepLogEntry is engine-local.

One adjacent finding went to its own card, #14184, and remains open: try_catch with no
catch region returns a failing result and deliberately withholds its childSteps, citing
the same engine asymmetry. This PR closes that asymmetry for the throw path only and leaves
the engine's returned-failure branch alone, since no executor returns childSteps there
today and a fold for zero producers would be speculative — so a try_catch with no handler
still loses its try-region record.

Generated by Claude Code


Generated by Claude Code

…the run log
A `loop` that died mid-sweep discarded its body's `childSteps` wholesale: the
engine splices them into the run's step list only after a SUCCESSFUL node
result, and a loop whose body throws never produces a result at all. A run that
genuinely performed 3 flag writes and 2 notifies before dying reported
`{selected: 5, acted: 0}` and kept no step record of writes already committed.
The danger is the direction, not the arithmetic: `acted: 0` on a failed sweep
reads as "nothing happened, safe to re-run", which for a non-idempotent body
invites double-execution.
A dying container now carries its completed body steps out on the thrown error
-- a non-enumerable symbol brand, the idiom #3863 already uses to mark
un-routable guard refusals on this same throw path -- and the engine's catch
path folds them into the run log where the success path splices `childSteps`.
The error is rethrown unchanged, so failure propagation, `fault`-edge routing,
`$error` and the run's reported error are all untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from bfe2575a0eca030611b87310217e96d824541ca8 — the merge of head dbc1d661a112f353bb9e05132f5bcb00e144821a into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bfe2575a0eca030611b87310217e96d824541ca8 && git checkout bfe2575a0eca030611b87310217e96d824541ca8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 dbc1d661a112f353bb9e05132f5bcb00e144821a && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff dbc1d661a112f353bb9e05132f5bcb00e144821a
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
Collaborator

PM 复核:通过 —— 落地前检已过,CI 全绿即转 ready + auto-merge

落地前检按最终 5 条路径当场重跑:0 of 5 path(s) hit the register ⇒ 非受管面。
Clause-②: no 采纳,并已独立复核:5 个文件无一在 packages/spec/**;partial-steps.ts 虽有导出,但 src/index.ts不在 diff 里,本席另查桶文件确认它不引用该模块 ⇒ 模块内部,未扩大发布面

⭐ 本轮最值钱的不是修复,是你证伪了两条候选修法

派发令说「三条路都未考察,自己量、自己选、写明为什么是这条」。你真的量了,而且量出了反直觉的结果:

  1. 「splice childSteps before result grading」—— 卡自己的第一条建议 —— 你孤立实现后实测它是 no-op。 理由干净利落:loop 是而不是返回,引擎走 catch 臂,根本没有 result 对象可评级,摘要照旧 acted: 0。⇒ 一个只读卡片就动手的实现会照着这条做,然后得到一个看起来合理、实际什么都没修的 diff。
  2. summary-side 修复 —— 你判它「计数是缺失,不是欠聚合」,而诚实版本需要给摘要加字段 ⇒ 那是条款②路径肢。⇒ 你不是绕开了停手条件,是选了一条不触发它的正确路
  3. 「flush per-iteration」 —— 需要 run 的 step 数组,而 NodeExecutor.execute 拿不到。

⭐ 而被否掉的第四条,理由是语义安全,不是风格

The alternative shape — having loop swallow the failure and return { success: false } — was rejected for precisely this reason: it would have made a guard refusal raised inside a loop body routable by a fault edge on the loop, the one-edge switch #3863 exists to prevent.

⇒ 一个「记录级」修复若顺手把 loop 改成返回失败,就会把不可路由的守卫拒绝变成可路由的。这是行为面的改变,而本卡是记录面的卡。看出这一点需要读 #3863 为什么存在,不是读 loop 的代码就能得到。

证据形状,逐条对上

  • 方向性 pin 就是卡本身:expectNeverUnderReports 断言 reported acted永不低于库里实际写数,而不是「现在等于 5」。ablation 下它打印的失败信息本身就是这张卡:"reported acted=0 is LOWER than the 5 writes that actually happened — an operator reading this concludes 'nothing happened, safe to re-run'"。⇒ 断言的措辞把危害方向写进了失败输出。
  • 反向控制做到了它该做的:首节点即失败、零写入 ⇒ 仍报 acted: 0(那里 0 才是诚实答案),且在 ablation 下保持绿 —— 按构造对本修复不敏感。⇒ 这正是那条防「把 selected 抄给 acted」的闸;你还补了第二个反向例(首元素写了一次才失败 ⇒ 报 1,不是 0 也不是 3)。
  • 控制项前后逐字节比对:全成功 sweep 与 try_catch 包住的路径(A loop node aborts the entire flow run when one iteration's node fails — a single bad row kills a whole scheduled sweep, and there is no per-iteration containment to opt into #13681 的面)取了 sha 对比(6b1579d58b3b9404 / 7bf718661e8be578),失败运行的传播字段(successerrorrun.status)也逐字节相同。
  • 嵌套不重复计数有专测:loop 套 loop,断言 acted恰好等于真实写数(折叠两次会把它顶到真实值之上,等式能抓到),并断言 new Set(steps).size === steps.length
  • 无 rebuild 腿是被证明的而非假设:只改 service-automation/src 不重建就改变了测得结果(acted 0 → 5)⇒ vitest 走源码,dist 不在解析路径上。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿未当红。

⚠️ 一处设计选择值得记下来,因为它是「差点变成崩溃」的那种

brand 被刻意设为可写,注释说明了原因:不可写的 brand 会让第二个展开的容器抛 TypeError,把一次嵌套 loop 失败变成引擎崩溃。⇒ 一个看起来更"严格"的选择在这里是错的,而你把理由写在了代码旁边,不是留给下一个人重新踩。

衍生卡

#14184 已定级路由:bug · p3 · domain:services · pm:queue —— try_catchcatch 区时在返回失败路径上丢弃 try 区 step 记录,是同一处不对称的另一半。⭐ 你刻意只修抛出路径、把返回失败那半留着不动(理由:今天没有任何 executor 在那条路上返回 childSteps,折叠会是投机性的)—— 这个边界画得对,记在案。


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(service-automation): keep a dying loop's completed body steps in the run log - #14185

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure
Sep 1, 2026
Merged

fix(service-automation): keep a dying loop's completed body steps in the run log#14185
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13803

A loop that dies mid-sweep discarded its body's completed steps wholesale. The engine
splices a container's childSteps into the run's step list only after a successful
node result, and a loop whose body throws never produces a result at all — the throw
unwinds straight past the splice, taking the accumulated array with the stack frame.

Reproduced on this branch's base (682d03ba7), not from the card's 43e18f51 cite, with
the real AutomationEngine and the card's own five-row shape (row 3 has a null owner, so
notify fails):

flagged = ["c1","c2","c3"] notified = ["c1","c2"] -> 5 writes really happened
summary = { selected: 5, acted: 0, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure) ] -> no record of any of them

The direction is the defect, not the arithmetic. An operator reading acted: 0 on a
failed sweep reasonably concludes "nothing happened, safe to re-run" — but the writes did
happen. For a non-idempotent body (notifications, counters, external calls) that misread
invites double-execution, and for auditing the rows exist in the data with no step record
to explain them. The run summary is the platform's own honesty instrument (#4354), and on
this path it was wrong in the one direction that causes harm.

Why this fix and not the other two

The card named three unexamined candidates. All three were measured before choosing.

A summary-side repair — rejected on evidence.summarizeRun is a pure fold over the
flat step log, and on the failing path that log holds three entries: start, query, and
the loop's own failure step, which carries no metrics. The counts are not
under-aggregated, they are absent. A summary-side repair could therefore only invent
numbers it has no record of, or degrade to reporting the sweep as unmeasured — and the
honest version of the latter needs a field on the summary, which is
packages/spec/src/contracts/automation-service.ts and the clause-② path limb. Rejected
without writing code: the data has to be preserved, not reinterpreted.

Splice childSteps before result grading — measured, and it is a no-op here. This one
was implemented in isolation and run, because the reasoning is easy to get wrong. Adding
the childSteps splice to the engine's if (!result.success) branch changes nothing on
this path: the loop does not return a failing result, it throws, so the engine's
catch (execErr) arm runs instead and there is no result object to splice from. The
measured summary after that change was still { selected: 5, acted: 0 }, and the step log
was still three entries. The tell is visible in the log without the experiment: the
loop's step carries EXECUTION_ERROR, which only the catch arm emits. The mutation was
confirmed on disk by blob hash and marker count before the run, and reverted with
git diff HEAD proven empty afterwards.

Flush per-iteration — the right instinct, in the wrong place. For the loop to write
body steps into the run log as it goes, it needs the run's step array, and
NodeExecutor.execute(node, variables, context) does not receive it. Reaching it means
widening the executor contract for every executor to serve one container.

What shipped keeps the per-iteration instinct but uses the seam #7546 already built.
runRegion grew a partialSteps sink for exactly this — surfacing a failed region's
partial steps — and wired only try_catch to it, noting that "callers that do not pass a
sink (loop, parallel) are unaffected". So:

  1. loop now passes its childSteps accumulator as that sink, which captures the
    failing iteration's steps too (success returns them instead, so nothing is counted
    twice), and
  2. the dying container carries the accumulated array out on the thrown error as a
    non-enumerable symbol brand, which the engine's catch arm folds into the run log in the
    same position the success path splices childSteps — behind the container's own step,
    ahead of any fault handler's.

The brand is not a new idea in this package: #3863's markGuardRefusal already carries
"this failure is un-routable" out through this identical throw path the same way.
#7546 declined the exception channel for runRegion because it "would either change what
callers catch or require a bespoke error type" — neither cost lands here, since this is
the opposite direction (executor back to engine) and the error object is passed through
untouched rather than wrapped.

What it means under partial failure

The run still fails. Nothing about accept/reject behaviour moves — that was the deciding
constraint, and it is why the losing alternative lost. Having loop swallow the failure
and return { success: false, childSteps } would have made the engine's failure branch
work, but it would also rewrite the run's error text (Node 'notify' failed: ... becomes
Node 'each' failed: Node 'notify' failed: ...), change the container step's code from
EXECUTION_ERROR to NODE_FAILURE, set $error where it previously stayed unset, and —
decisively — make a guard refusal raised inside a loop body routable by a fault edge on
the loop. That is precisely the one-edge switch #3863 exists to prevent. A record fix must
not move accept/reject behaviour, so the error is rethrown with its identity, message and
guard-refusal marking intact.

What changes is only the record, and only for iterations that actually ran:

summary = { selected: 5, acted: 5, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure),
flag@0 ok, notify@0 ok, flag@1 ok, notify@1 ok, flag@2 ok, notify@2 FAILURE ]

Rows 4 and 5 were never entered and no step claims they were. The per-node breakdown now
lists the body nodes that ran (flag runs 3 acted 3; notify runs 3 failures 1 acted 2)
instead of only the container. Every folded step carries a parentNodeId set by
runRegion's tagger, so the ADR-0044 runaway guard — which counts only top-level visits —
cannot see them either.

Nesting is handled rather than assumed: as a failure unwinds through nested containers each
one re-brands the same error with its own accumulator, and because an outer container's
sink has already absorbed the inner one's steps, the last write is a superset of every
earlier one and each step object still reaches the log exactly once. The brand is writable
for this reason — a non-writable one would make the second container to unwind throw a
TypeError, turning a nested-loop failure into an engine crash. There is a test for it.

Verification

All readings below are at dbc1d661a, the branch head.

The directional assertion is the pin.expectNeverUnderReports asserts the reported
acted is never lower than the writes the store actually holds, rather than that it
equals 3 or 5 — the direction survives a refactor, the literal does not. Under ablation it
reads:

reported acted=0 is LOWER than the 5 writes that actually happened —
an operator reading this concludes "nothing happened, safe to re-run"

Reverse control. A sweep whose body's first node fails writes nothing and must still
report acted: 0, because there that is the honest answer. It does, and it stays green
under the ablation — it is insensitive to this fix by construction, which is exactly what
stops the repair degenerating into copying selected into acted. A second reverse case
pins the boundary: failing on the first element after one write reports acted: 1, not 0
and not selected.

Controls, declared as controls and not offered as ablation evidence. An all-succeeding
sweep and the try_catch-contained path (#13681's face) were captured before and after the
change and are byte-identical — the measured lines diff clean, sha 6b1579d5... and
7bf71866... respectively. The failing run's propagation fields (success, error,
run.status) are byte-identical too.

Ablation. Direction predicted first: removing the engine-side fold should redden the
six tests that read the recovered record and leave the four that do not. Measured exactly
that — 6 failed, 4 passed, and the four greens were the predicted ones. The mutation was
confirmed on disk before the run (deleted-text count 1 to 0, injected marker 0 to 1, blob
b5e4b3f5... to 19682f89...), never from an editor's exit code, and the restore was
proven by state afterwards (git diff HEAD empty, blob back to b5e4b3f5...), not by a
trap firing. No rebuild leg applies: the subject is reached through relative same-package
imports (../engine.js, ./loop-node.js), so vitest resolves source, never dist
confirmed empirically, since editing only service-automation/src without rebuilding that
package changed the measured result.

Suites and gates.

  • pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=295
    files, 1133 tests, all pass
    .
  • Gate family re-derived from the actual diff with
    node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read
    whole: 30 path-derived plus 7 convention-triggered. 34 of 35 pass. The one exception
    is scripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET locally
    because it grades a tee'd turbo run test log that only CI produces; its own output says
    to record it as NOT MEASURED and that it "is not a red". Recorded as NOT MEASURED, not as
    a pass. Every exit code was captured before any pipe.
  • pnpm check:type-check-debt (the --re-measure ratchet) passes with the workspace
    closure built, so the ledgered count did not drift.
  • pnpm lint (eslint . --no-inline-config, whole repo) — exit 0, 64s. No narrowing.
  • pnpm check:nul-bytes — clean over 7741 files; the diff was also scanned directly for
    raw control bytes.
  • tsc --listFiles lists all four touched sources, the new test file included, and the
    only errors reported are the three pre-existing ledgered TS2341 in
    nested-region-parity.test.ts, a file this PR does not touch.

Scope

Deliberately not addressed here, and each remains open: #13681's ruled B-branch is the
contained path's docs and lint visibility, whereas this is the uncontained path's
summary under-reporting; the resume ordering, forgetSuspendedRun and traverseNext are
#13937's decision surface in the same file; and restoreConsumedSuspension is untouched.
No file under packages/spec/ is modified, so the declared shapes of AutomationResult,
the run summary and steps are unchanged — StepLogEntry is engine-local.

One adjacent finding went to its own card, #14184, and remains open: try_catch with no
catch region returns a failing result and deliberately withholds its childSteps, citing
the same engine asymmetry. This PR closes that asymmetry for the throw path only and leaves
the engine's returned-failure branch alone, since no executor returns childSteps there
today and a fold for zero producers would be speculative — so a try_catch with no handler
still loses its try-region record.

Generated by Claude Code


Generated by Claude Code

…the run log
A `loop` that died mid-sweep discarded its body's `childSteps` wholesale: the
engine splices them into the run's step list only after a SUCCESSFUL node
result, and a loop whose body throws never produces a result at all. A run that
genuinely performed 3 flag writes and 2 notifies before dying reported
`{selected: 5, acted: 0}` and kept no step record of writes already committed.
The danger is the direction, not the arithmetic: `acted: 0` on a failed sweep
reads as "nothing happened, safe to re-run", which for a non-idempotent body
invites double-execution.
A dying container now carries its completed body steps out on the thrown error
-- a non-enumerable symbol brand, the idiom #3863 already uses to mark
un-routable guard refusals on this same throw path -- and the engine's catch
path folds them into the run log where the success path splices `childSteps`.
The error is rethrown unchanged, so failure propagation, `fault`-edge routing,
`$error` and the run's reported error are all untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from bfe2575a0eca030611b87310217e96d824541ca8 — the merge of head dbc1d661a112f353bb9e05132f5bcb00e144821a into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bfe2575a0eca030611b87310217e96d824541ca8 && git checkout bfe2575a0eca030611b87310217e96d824541ca8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 dbc1d661a112f353bb9e05132f5bcb00e144821a && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff dbc1d661a112f353bb9e05132f5bcb00e144821a
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
Collaborator

PM 复核:通过 —— 落地前检已过,CI 全绿即转 ready + auto-merge

落地前检按最终 5 条路径当场重跑:0 of 5 path(s) hit the register ⇒ 非受管面。
Clause-②: no 采纳,并已独立复核:5 个文件无一在 packages/spec/**;partial-steps.ts 虽有导出,但 src/index.ts不在 diff 里,本席另查桶文件确认它不引用该模块 ⇒ 模块内部,未扩大发布面

⭐ 本轮最值钱的不是修复,是你证伪了两条候选修法

派发令说「三条路都未考察,自己量、自己选、写明为什么是这条」。你真的量了,而且量出了反直觉的结果:

  1. 「splice childSteps before result grading」—— 卡自己的第一条建议 —— 你孤立实现后实测它是 no-op。 理由干净利落:loop 是而不是返回,引擎走 catch 臂,根本没有 result 对象可评级,摘要照旧 acted: 0。⇒ 一个只读卡片就动手的实现会照着这条做,然后得到一个看起来合理、实际什么都没修的 diff。
  2. summary-side 修复 —— 你判它「计数是缺失,不是欠聚合」,而诚实版本需要给摘要加字段 ⇒ 那是条款②路径肢。⇒ 你不是绕开了停手条件,是选了一条不触发它的正确路
  3. 「flush per-iteration」 —— 需要 run 的 step 数组,而 NodeExecutor.execute 拿不到。

⭐ 而被否掉的第四条,理由是语义安全,不是风格

The alternative shape — having loop swallow the failure and return { success: false } — was rejected for precisely this reason: it would have made a guard refusal raised inside a loop body routable by a fault edge on the loop, the one-edge switch #3863 exists to prevent.

⇒ 一个「记录级」修复若顺手把 loop 改成返回失败,就会把不可路由的守卫拒绝变成可路由的。这是行为面的改变,而本卡是记录面的卡。看出这一点需要读 #3863 为什么存在,不是读 loop 的代码就能得到。

证据形状,逐条对上

  • 方向性 pin 就是卡本身:expectNeverUnderReports 断言 reported acted永不低于库里实际写数,而不是「现在等于 5」。ablation 下它打印的失败信息本身就是这张卡:"reported acted=0 is LOWER than the 5 writes that actually happened — an operator reading this concludes 'nothing happened, safe to re-run'"。⇒ 断言的措辞把危害方向写进了失败输出。
  • 反向控制做到了它该做的:首节点即失败、零写入 ⇒ 仍报 acted: 0(那里 0 才是诚实答案),且在 ablation 下保持绿 —— 按构造对本修复不敏感。⇒ 这正是那条防「把 selected 抄给 acted」的闸;你还补了第二个反向例(首元素写了一次才失败 ⇒ 报 1,不是 0 也不是 3)。
  • 控制项前后逐字节比对:全成功 sweep 与 try_catch 包住的路径(A loop node aborts the entire flow run when one iteration's node fails — a single bad row kills a whole scheduled sweep, and there is no per-iteration containment to opt into #13681 的面)取了 sha 对比(6b1579d58b3b9404 / 7bf718661e8be578),失败运行的传播字段(successerrorrun.status)也逐字节相同。
  • 嵌套不重复计数有专测:loop 套 loop,断言 acted恰好等于真实写数(折叠两次会把它顶到真实值之上,等式能抓到),并断言 new Set(steps).size === steps.length
  • 无 rebuild 腿是被证明的而非假设:只改 service-automation/src 不重建就改变了测得结果(acted 0 → 5)⇒ vitest 走源码,dist 不在解析路径上。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿未当红。

⚠️ 一处设计选择值得记下来,因为它是「差点变成崩溃」的那种

brand 被刻意设为可写,注释说明了原因:不可写的 brand 会让第二个展开的容器抛 TypeError,把一次嵌套 loop 失败变成引擎崩溃。⇒ 一个看起来更"严格"的选择在这里是错的,而你把理由写在了代码旁边,不是留给下一个人重新踩。

衍生卡

#14184 已定级路由:bug · p3 · domain:services · pm:queue —— try_catchcatch 区时在返回失败路径上丢弃 try 区 step 记录,是同一处不对称的另一半。⭐ 你刻意只修抛出路径、把返回失败那半留着不动(理由:今天没有任何 executor 在那条路上返回 childSteps,折叠会是投机性的)—— 这个边界画得对,记在案。


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(service-automation): keep a dying loop's completed body steps in the run log - #14185

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure
Sep 1, 2026
Merged

fix(service-automation): keep a dying loop's completed body steps in the run log#14185
os-steve merged 1 commit into
mainfrom
claude/issue-13803-loop-childsteps-on-failure

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes#13803

A loop that dies mid-sweep discarded its body's completed steps wholesale. The engine
splices a container's childSteps into the run's step list only after a successful
node result, and a loop whose body throws never produces a result at all — the throw
unwinds straight past the splice, taking the accumulated array with the stack frame.

Reproduced on this branch's base (682d03ba7), not from the card's 43e18f51 cite, with
the real AutomationEngine and the card's own five-row shape (row 3 has a null owner, so
notify fails):

flagged = ["c1","c2","c3"] notified = ["c1","c2"] -> 5 writes really happened
summary = { selected: 5, acted: 0, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure) ] -> no record of any of them

The direction is the defect, not the arithmetic. An operator reading acted: 0 on a
failed sweep reasonably concludes "nothing happened, safe to re-run" — but the writes did
happen. For a non-idempotent body (notifications, counters, external calls) that misread
invites double-execution, and for auditing the rows exist in the data with no step record
to explain them. The run summary is the platform's own honesty instrument (#4354), and on
this path it was wrong in the one direction that causes harm.

Why this fix and not the other two

The card named three unexamined candidates. All three were measured before choosing.

A summary-side repair — rejected on evidence.summarizeRun is a pure fold over the
flat step log, and on the failing path that log holds three entries: start, query, and
the loop's own failure step, which carries no metrics. The counts are not
under-aggregated, they are absent. A summary-side repair could therefore only invent
numbers it has no record of, or degrade to reporting the sweep as unmeasured — and the
honest version of the latter needs a field on the summary, which is
packages/spec/src/contracts/automation-service.ts and the clause-② path limb. Rejected
without writing code: the data has to be preserved, not reinterpreted.

Splice childSteps before result grading — measured, and it is a no-op here. This one
was implemented in isolation and run, because the reasoning is easy to get wrong. Adding
the childSteps splice to the engine's if (!result.success) branch changes nothing on
this path: the loop does not return a failing result, it throws, so the engine's
catch (execErr) arm runs instead and there is no result object to splice from. The
measured summary after that change was still { selected: 5, acted: 0 }, and the step log
was still three entries. The tell is visible in the log without the experiment: the
loop's step carries EXECUTION_ERROR, which only the catch arm emits. The mutation was
confirmed on disk by blob hash and marker count before the run, and reverted with
git diff HEAD proven empty afterwards.

Flush per-iteration — the right instinct, in the wrong place. For the loop to write
body steps into the run log as it goes, it needs the run's step array, and
NodeExecutor.execute(node, variables, context) does not receive it. Reaching it means
widening the executor contract for every executor to serve one container.

What shipped keeps the per-iteration instinct but uses the seam #7546 already built.
runRegion grew a partialSteps sink for exactly this — surfacing a failed region's
partial steps — and wired only try_catch to it, noting that "callers that do not pass a
sink (loop, parallel) are unaffected". So:

  1. loop now passes its childSteps accumulator as that sink, which captures the
    failing iteration's steps too (success returns them instead, so nothing is counted
    twice), and
  2. the dying container carries the accumulated array out on the thrown error as a
    non-enumerable symbol brand, which the engine's catch arm folds into the run log in the
    same position the success path splices childSteps — behind the container's own step,
    ahead of any fault handler's.

The brand is not a new idea in this package: #3863's markGuardRefusal already carries
"this failure is un-routable" out through this identical throw path the same way.
#7546 declined the exception channel for runRegion because it "would either change what
callers catch or require a bespoke error type" — neither cost lands here, since this is
the opposite direction (executor back to engine) and the error object is passed through
untouched rather than wrapped.

What it means under partial failure

The run still fails. Nothing about accept/reject behaviour moves — that was the deciding
constraint, and it is why the losing alternative lost. Having loop swallow the failure
and return { success: false, childSteps } would have made the engine's failure branch
work, but it would also rewrite the run's error text (Node 'notify' failed: ... becomes
Node 'each' failed: Node 'notify' failed: ...), change the container step's code from
EXECUTION_ERROR to NODE_FAILURE, set $error where it previously stayed unset, and —
decisively — make a guard refusal raised inside a loop body routable by a fault edge on
the loop. That is precisely the one-edge switch #3863 exists to prevent. A record fix must
not move accept/reject behaviour, so the error is rethrown with its identity, message and
guard-refusal marking intact.

What changes is only the record, and only for iterations that actually ran:

summary = { selected: 5, acted: 5, skipped: 0, unmeasured: 0 }
step log = [ start, query, each(failure),
flag@0 ok, notify@0 ok, flag@1 ok, notify@1 ok, flag@2 ok, notify@2 FAILURE ]

Rows 4 and 5 were never entered and no step claims they were. The per-node breakdown now
lists the body nodes that ran (flag runs 3 acted 3; notify runs 3 failures 1 acted 2)
instead of only the container. Every folded step carries a parentNodeId set by
runRegion's tagger, so the ADR-0044 runaway guard — which counts only top-level visits —
cannot see them either.

Nesting is handled rather than assumed: as a failure unwinds through nested containers each
one re-brands the same error with its own accumulator, and because an outer container's
sink has already absorbed the inner one's steps, the last write is a superset of every
earlier one and each step object still reaches the log exactly once. The brand is writable
for this reason — a non-writable one would make the second container to unwind throw a
TypeError, turning a nested-loop failure into an engine crash. There is a test for it.

Verification

All readings below are at dbc1d661a, the branch head.

The directional assertion is the pin.expectNeverUnderReports asserts the reported
acted is never lower than the writes the store actually holds, rather than that it
equals 3 or 5 — the direction survives a refactor, the literal does not. Under ablation it
reads:

reported acted=0 is LOWER than the 5 writes that actually happened —
an operator reading this concludes "nothing happened, safe to re-run"

Reverse control. A sweep whose body's first node fails writes nothing and must still
report acted: 0, because there that is the honest answer. It does, and it stays green
under the ablation — it is insensitive to this fix by construction, which is exactly what
stops the repair degenerating into copying selected into acted. A second reverse case
pins the boundary: failing on the first element after one write reports acted: 1, not 0
and not selected.

Controls, declared as controls and not offered as ablation evidence. An all-succeeding
sweep and the try_catch-contained path (#13681's face) were captured before and after the
change and are byte-identical — the measured lines diff clean, sha 6b1579d5... and
7bf71866... respectively. The failing run's propagation fields (success, error,
run.status) are byte-identical too.

Ablation. Direction predicted first: removing the engine-side fold should redden the
six tests that read the recovered record and leave the four that do not. Measured exactly
that — 6 failed, 4 passed, and the four greens were the predicted ones. The mutation was
confirmed on disk before the run (deleted-text count 1 to 0, injected marker 0 to 1, blob
b5e4b3f5... to 19682f89...), never from an editor's exit code, and the restore was
proven by state afterwards (git diff HEAD empty, blob back to b5e4b3f5...), not by a
trap firing. No rebuild leg applies: the subject is reached through relative same-package
imports (../engine.js, ./loop-node.js), so vitest resolves source, never dist
confirmed empirically, since editing only service-automation/src without rebuilding that
package changed the measured result.

Suites and gates.

  • pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=295
    files, 1133 tests, all pass
    .
  • Gate family re-derived from the actual diff with
    node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, both sections read
    whole: 30 path-derived plus 7 convention-triggered. 34 of 35 pass. The one exception
    is scripts/check-test-completeness.mjs, which exits 3 = PREREQUISITE NOT MET locally
    because it grades a tee'd turbo run test log that only CI produces; its own output says
    to record it as NOT MEASURED and that it "is not a red". Recorded as NOT MEASURED, not as
    a pass. Every exit code was captured before any pipe.
  • pnpm check:type-check-debt (the --re-measure ratchet) passes with the workspace
    closure built, so the ledgered count did not drift.
  • pnpm lint (eslint . --no-inline-config, whole repo) — exit 0, 64s. No narrowing.
  • pnpm check:nul-bytes — clean over 7741 files; the diff was also scanned directly for
    raw control bytes.
  • tsc --listFiles lists all four touched sources, the new test file included, and the
    only errors reported are the three pre-existing ledgered TS2341 in
    nested-region-parity.test.ts, a file this PR does not touch.

Scope

Deliberately not addressed here, and each remains open: #13681's ruled B-branch is the
contained path's docs and lint visibility, whereas this is the uncontained path's
summary under-reporting; the resume ordering, forgetSuspendedRun and traverseNext are
#13937's decision surface in the same file; and restoreConsumedSuspension is untouched.
No file under packages/spec/ is modified, so the declared shapes of AutomationResult,
the run summary and steps are unchanged — StepLogEntry is engine-local.

One adjacent finding went to its own card, #14184, and remains open: try_catch with no
catch region returns a failing result and deliberately withholds its childSteps, citing
the same engine asymmetry. This PR closes that asymmetry for the throw path only and leaves
the engine's returned-failure branch alone, since no executor returns childSteps there
today and a fold for zero producers would be speculative — so a try_catch with no handler
still loses its try-region record.

Generated by Claude Code


Generated by Claude Code

…the run log
A `loop` that died mid-sweep discarded its body's `childSteps` wholesale: the
engine splices them into the run's step list only after a SUCCESSFUL node
result, and a loop whose body throws never produces a result at all. A run that
genuinely performed 3 flag writes and 2 notifies before dying reported
`{selected: 5, acted: 0}` and kept no step record of writes already committed.
The danger is the direction, not the arithmetic: `acted: 0` on a failed sweep
reads as "nothing happened, safe to re-run", which for a non-idempotent body
invites double-execution.
A dying container now carries its completed body steps out on the thrown error
-- a non-enumerable symbol brand, the idiom #3863 already uses to mark
un-routable guard refusals on this same throw path -- and the engine's catch
path folds them into the run log where the success path splices `childSteps`.
The error is rethrown unchanged, so failure propagation, `fault`-edge routing,
`$error` and the run's reported error are all untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073packageMentionDocs.

Which tree this was computed on

This run read content/docs from bfe2575a0eca030611b87310217e96d824541ca8 — the merge of head dbc1d661a112f353bb9e05132f5bcb00e144821a into base 45b9051248f86f362b042fa9de63295a8c224073, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bfe2575a0eca030611b87310217e96d824541ca8 && git checkout bfe2575a0eca030611b87310217e96d824541ca8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 45b9051248f86f362b042fa9de63295a8c224073 dbc1d661a112f353bb9e05132f5bcb00e144821a && git checkout -B drift-repro 45b9051248f86f362b042fa9de63295a8c224073 && git merge --no-ff dbc1d661a112f353bb9e05132f5bcb00e144821a
node scripts/docs-audit/affected-docs.mjs --json 45b9051248f86f362b042fa9de63295a8c224073

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@os-steveClaude

Copy link
Copy Markdown
Collaborator

PM 复核:通过 —— 落地前检已过,CI 全绿即转 ready + auto-merge

落地前检按最终 5 条路径当场重跑:0 of 5 path(s) hit the register ⇒ 非受管面。
Clause-②: no 采纳,并已独立复核:5 个文件无一在 packages/spec/**;partial-steps.ts 虽有导出,但 src/index.ts不在 diff 里,本席另查桶文件确认它不引用该模块 ⇒ 模块内部,未扩大发布面

⭐ 本轮最值钱的不是修复,是你证伪了两条候选修法

派发令说「三条路都未考察,自己量、自己选、写明为什么是这条」。你真的量了,而且量出了反直觉的结果:

  1. 「splice childSteps before result grading」—— 卡自己的第一条建议 —— 你孤立实现后实测它是 no-op。 理由干净利落:loop 是而不是返回,引擎走 catch 臂,根本没有 result 对象可评级,摘要照旧 acted: 0。⇒ 一个只读卡片就动手的实现会照着这条做,然后得到一个看起来合理、实际什么都没修的 diff。
  2. summary-side 修复 —— 你判它「计数是缺失,不是欠聚合」,而诚实版本需要给摘要加字段 ⇒ 那是条款②路径肢。⇒ 你不是绕开了停手条件,是选了一条不触发它的正确路
  3. 「flush per-iteration」 —— 需要 run 的 step 数组,而 NodeExecutor.execute 拿不到。

⭐ 而被否掉的第四条,理由是语义安全,不是风格

The alternative shape — having loop swallow the failure and return { success: false } — was rejected for precisely this reason: it would have made a guard refusal raised inside a loop body routable by a fault edge on the loop, the one-edge switch #3863 exists to prevent.

⇒ 一个「记录级」修复若顺手把 loop 改成返回失败,就会把不可路由的守卫拒绝变成可路由的。这是行为面的改变,而本卡是记录面的卡。看出这一点需要读 #3863 为什么存在,不是读 loop 的代码就能得到。

证据形状,逐条对上

  • 方向性 pin 就是卡本身:expectNeverUnderReports 断言 reported acted永不低于库里实际写数,而不是「现在等于 5」。ablation 下它打印的失败信息本身就是这张卡:"reported acted=0 is LOWER than the 5 writes that actually happened — an operator reading this concludes 'nothing happened, safe to re-run'"。⇒ 断言的措辞把危害方向写进了失败输出。
  • 反向控制做到了它该做的:首节点即失败、零写入 ⇒ 仍报 acted: 0(那里 0 才是诚实答案),且在 ablation 下保持绿 —— 按构造对本修复不敏感。⇒ 这正是那条防「把 selected 抄给 acted」的闸;你还补了第二个反向例(首元素写了一次才失败 ⇒ 报 1,不是 0 也不是 3)。
  • 控制项前后逐字节比对:全成功 sweep 与 try_catch 包住的路径(A loop node aborts the entire flow run when one iteration's node fails — a single bad row kills a whole scheduled sweep, and there is no per-iteration containment to opt into #13681 的面)取了 sha 对比(6b1579d58b3b9404 / 7bf718661e8be578),失败运行的传播字段(successerrorrun.status)也逐字节相同。
  • 嵌套不重复计数有专测:loop 套 loop,断言 acted恰好等于真实写数(折叠两次会把它顶到真实值之上,等式能抓到),并断言 new Set(steps).size === steps.length
  • 无 rebuild 腿是被证明的而非假设:只改 service-automation/src 不重建就改变了测得结果(acted 0 → 5)⇒ vitest 走源码,dist 不在解析路径上。
  • check-test-completeness 退出 3 = PREREQUISITE NOT MET,记为 NOT MEASURED,⛔ 未当绿未当红。

⚠️ 一处设计选择值得记下来,因为它是「差点变成崩溃」的那种

brand 被刻意设为可写,注释说明了原因:不可写的 brand 会让第二个展开的容器抛 TypeError,把一次嵌套 loop 失败变成引擎崩溃。⇒ 一个看起来更"严格"的选择在这里是错的,而你把理由写在了代码旁边,不是留给下一个人重新踩。

衍生卡

#14184 已定级路由:bug · p3 · domain:services · pm:queue —— try_catchcatch 区时在返回失败路径上丢弃 try 区 step 记录,是同一处不对称的另一半。⭐ 你刻意只修抛出路径、把返回失败那半留着不动(理由:今天没有任何 executor 在那条路上返回 childSteps,折叠会是投机性的)—— 这个边界画得对,记在案。


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude