Skip to content

fix(service-automation): seed a retry attempt's variable environment through the same chokepoint as attempt 1 - #9888

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-9704-retry-variable-env
Aug 19, 2026
Merged

fix(service-automation): seed a retry attempt's variable environment through the same chokepoint as attempt 1#9888
os-warren merged 2 commits into
mainfrom
claude/issue-9704-retry-variable-env

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#9704

The defect

executeWithoutRetry() — the method retryExecution re-runs the flow through on every retry attempt — seeded only seedDeclaredVariables(flow, context) and $record. execute() seeds all of that plusrecord (a friendlier alias), the trigger record's fields flattened to top-level names, previous, $runId, $flowName and $flowLabel.

So a retry attempt ran in a strictly smaller variable environment than attempt 1. Conditions are strict CEL, where reading an unbound name ABORTS the predicate (Unknown variable: X) rather than yielding false (the mechanism #4697 documents), which makes the divergence user-visible exactly where retry is most used — errorHandling.strategy: 'retry' on a record-change flow:

Premise re-derivation

The card measured at ed4ca5999; the repair for #9510 (PR #9785) landed on engine.ts afterwards. Re-derived fresh against merged main (55d29935b): the divergence is intact and complete — the five seedings named are exactly the delta, with no additions and none already repaired. Only the line anchors moved.

The repair

Per the dispatching seat's route ruling (shared helper hoist, over passing the seeded map from the caller), following the buildRunTrigger chokepoint precedent and the four prior drift cards on these two methods (#9378, #9415, #9414, #9510 — all in the same direction: the copy that is not execute() is the one a repair forgets):

  • new private seedRunVariables(flow, flowName, context, runId) holds execute()'s seeding block verbatim, in the same order (declared variables first, so the record flattening cannot shadow a flow input);
  • both execute() and executeWithoutRetry() call it;
  • the run id is minted before the seeding in both, because $runId is part of the environment. Order-safe: nextRunId() is a stateless random id with no counter to advance, and nothing between the old and new call sites reads or mints one. The caller still owns the id (it keys the log row, the continuation and the returned envelope) rather than the helper minting a second one that would name no resumable run.

First-attempt behaviour is unchanged by construction and by measurement (see below).

The parity pin flips substantively

retry-attempt-pause.test.ts (from #9510) pinned this divergence explicitly so it could not be repaired silently, and its own comment named the correct edit. The three not.toContain assertions are replaced by assertions of the new semantics, not merely deleted:

  • the two variable snapshots are compared to each other ($runId normalized), the same discipline the result envelope above it already used;
  • $runId is asserted by value against the run id each route returned — a snapshot merely carrying some$runId is the ADR-0019 mapping hole this card is about, and a presence-only check cannot see it;
  • $flowName, $flowLabel, previous (via toHaveProperty, since absent and null both read as null through ?.), record and the flattened amount / id are pinned by value on the retry route — because the parity comparison alone is equally satisfied if both routes lose them, which is the shape a later "simplification" of the shared helper would take.

Every genuinely-illegal-shape assertion in the file is untouched, including the retry-accounting pins (still burns the full budget when attempts genuinely FAIL) and the resumed-run budget answer.

Verification

All at 0dc1f8406.

Red before green. With only the test flipped and engine.ts unrepaired: AssertionError: expected undefined to be 'run_c27e4d29-...' — the retry snapshot carried no $runId at all. After the engine fix: 6/6 pass.

First-attempt behaviour unchanged. Full package suite is 82 files / 988 tests passing, identical file and test counts to the pre-change baseline run on origin/main.

Ablation (both legs, red then byte-identically restored). The suite resolves engine.ts from source — this is an intra-package relative import (./engine.js), not a dependency resolved through exports to dist/, so no rebuild gates the measurement. That is not assumed: the red-before-green pair above proves it directly, since editing only the test went red and editing only engine.ts went green with no build in between.

  • removing variables.set('previous', ...) from the helper: AssertionError: expected { '$record': ..., ...(7) } to have property "previous" with value null;
  • removing the record alias and the flattening loop: AssertionError: expected undefined to deeply equal { id: 'ord_9510', amount: 500 } — and note this failed on the by-value assertion, not the parity comparison, which is exactly the guard those assertions were added for.

Both restored with git checkout HEAD -- src/engine.ts, verified byte-identical by blob hash (git hash-object = 2d61de4d9ceccd18b687490206f19da5bac9ac36 = git rev-parse HEAD:...) with clean porcelain, and the green run re-taken from the committed state.

Typecheck.npx tsc --noEmit -p tsconfig.json in the package: 3 errors, all pre-existing in nested-region-parity.test.ts (TS2341, private flows access), matching the debt ledger's recorded errors: 3 for this package — none in either changed file. Note pnpm --filter @objectstack/service-automation typecheck was deliberately not used as evidence: the package declares no typecheck script, so that invocation matches zero scripts and exits 0 without running anything.

Gates. Named by the dispatch, then re-derived against the actual diff with node scripts/pm/dispatch-gates.mjs (which derives its own change set from the merge base) and everything it added was run:

gateverdict line
check:cross-package-test-inputsOK: 12 package(s) read outside themselves, all declared
check:test-source-aliasOK — 72 packages with tests scanned
check:type-source-resolutionOK — 76 packages with a tsconfig.json scanned
scripts/check-cross-package-test-inputs.mjsOK: 12 package(s) read outside themselves
scripts/docs-audit/check-affected-docs.mjs✓ affected-docs self-test: 242 cases pass.
check:changeset-gate-self-testsall three self-tests pass
check:objectui-changeset✓ objectui-range --self-test: all checks passed
scripts/check-adr-0087-registration.mjs✓ this PR adds no declared-breaking changeset
scripts/check-changeset-no-major.mjs✓ This diff introduces no major bump.
scripts/check-empty-changeset.mjs✓ No empty-frontmatter changeset introduced
check:query-options-erasure✓ ratchet holds ... none new
check:engine-double-contractOK — 321 pinned, 133 in the DEBT ledger, 2 exempt
check:where-matcher✓ conformance holds: 258 matcher(s) ... none new
check:type-check-coverageOK — 64/77 workspace packages type-checked
check:type-check-debt--re-measure: OK — 33 ledger entr(ies) re-measured in 351.9s, 1926 raw tsc error(s) total, none above its recorded number.
check:nul-bytesOK (scanned 6282 text file(s) ... no raw ASCII control bytes)

check:type-check-debt refuses to measure without a built closure (it would otherwise measure a different world), so the full 70-package workspace build was run first, exactly as lint.yml does.

Changeset

@objectstack/service-automation is a published package (no private: true, publishConfig.access: public) and this is a user-visible behaviour change, so a patch changeset is included.

Repricing note for the serialized sibling #9705

Unaffected.#9705 is the resume path (resumeInternal never reads flow.errorHandling and never enters retryExecution), while this change touches only how the two execution entry points seed their variable map; the pin answering that question (a resumed run does not retry, on either route) is untouched and still green.


Generated by Claude Code

…through the same chokepoint as attempt 1
`executeWithoutRetry()` — the method `retryExecution` re-runs the flow
through on every retry attempt — seeded only the flow's declared variables
and `$record`, while `execute()` also binds `record` plus the trigger
record's flattened fields, `previous`, `$runId`, `$flowName` and
`$flowLabel`. A retry attempt therefore ran in a strictly smaller variable
environment than the first.
Under strict CEL an unbound name ABORTS the predicate rather than yielding
false (#4697), so a start condition or edge predicate reading `previous`
(#3427) or a bare record field failed on the retry for a reason attempt 1
never hit, and a pausing node on a retry attempt had no `$runId` to map its
external state back to this run with (ADR-0019).
Both methods now seed through one private `seedRunVariables` helper, the
`buildRunTrigger` chokepoint pattern. First-attempt behaviour is unchanged:
the helper is `execute()`'s own block verbatim, and the run id is now minted
before the seeding (order-safe — `nextRunId()` is a stateless random id).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 3 documentable anchor(s).

2 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via AutomationEngine (symbol))
  • content/docs/releases/v17.mdx(via AutomationEngine (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)

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 4f994de82c376102025f2e64d3c4205665094859packageMentionDocs.

Which tree this was computed on

This run read content/docs from dd9f3c24483e1b31398d5cd1b648dc1b744d0962 — the merge of head 0dc1f8406aee472b40d551814110d75e0a984105 into base 4f994de82c376102025f2e64d3c4205665094859, 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 dd9f3c24483e1b31398d5cd1b648dc1b744d0962 && git checkout dd9f3c24483e1b31398d5cd1b648dc1b744d0962
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4f994de82c376102025f2e64d3c4205665094859 0dc1f8406aee472b40d551814110d75e0a984105 && git checkout -B drift-repro 4f994de82c376102025f2e64d3c4205665094859 && git merge --no-ff 0dc1f8406aee472b40d551814110d75e0a984105
node scripts/docs-audit/affected-docs.mjs --json 4f994de82c376102025f2e64d3c4205665094859

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4f994de82c376102025f2e64d3c4205665094859 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 19, 2026
@os-warren
os-warren marked this pull request as ready for review August 19, 2026 06:53
@os-warren
os-warren added this pull request to the merge queueAug 19, 2026
Merged via the queue into main with commit b030055Aug 19, 2026
26 checks passed
@os-warren
os-warren deleted the claude/issue-9704-retry-variable-env branch August 19, 2026 07:16
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-warren@claude