Uh oh!
There was an error while loading. Please reload this page.
fix(service-automation): a retry attempt that PAUSES is a durable pause, not a failed attempt — executeWithoutRetry gets the ADR-0019 suspend arm (#9510) - #9785
Conversation
…se, not a failed attempt (#9510) `execute()`'s catch tests the suspend signal first, and that arm is what makes ADR-0019's durable pause work. `executeWithoutRetry()` — which `retryExecution` re-runs the flow through on every retry attempt — had no such arm, so a `FlowSuspendSignal` thrown on a retry attempt fell into the generic failure path: `persistSuspendedRun` never ran, so the continuation was never stored and the run could not be resumed by anyone; the run log recorded `failed`; the caller got `status: 'failed'`; and the retry loop, reading only `result.success`, counted the pause as one more failed attempt and burned the rest of the budget re-entering the pausing node. Lifts the suspend arm into `executeWithoutRetry`, and teaches both readers of the now non-terminal `retryExecution` result the third state deliberately: the retry loop returns a paused attempt because it paused, tested on `status` before the `success` check that means "this attempt succeeded"; the trigger route answers it from its own arm, off a named predicate on the shared flow-dispatch table. Retry accounting is untouched — a failing attempt still consumes one and `maxRetries` still bounds the loop. Both routes to a pause are pinned as an equality rather than verified in isolation, engine-side and end-to-end through a real dispatcher, so no caller can tell which attempt paused. Runs already lost to this defect are not recoverable: nothing was ever written for them. Refs #9414 / PR #9514 (the sibling repair on the same three methods), ADR-0019. Findings filed out of this work: #9704, #9705. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
…erdict from the companion field The reverse verification caught this: replacing `isPausedRun`'s body with `!!result.screen` — the tolerant-consumer sniff PD #12 forbids — left the whole runtime suite green. Every assertion in the pin was satisfied by it, because a screen happened to accompany the paused fixture and to be absent from all the negatives, so the test could not fail against the one shape it exists to forbid. Adds the two cases that separate the readings: an `approval`/`wait` pause with NO screen is still a pause, and a screen on a result that is not parked does not make it one. Both go red under that substitution. Also states plainly, at the route-level case, that this door answers a pause and a terminal success identically on the wire by design, so no route assertion can tell which arm produced the response — the discrimination lives on the predicate. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9460c6a3409322f8557ea55ccfbe3e6fe0028dde && git checkout 9460c6a3409322f8557ea55ccfbe3e6fe0028dde
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 985a9cd2dbbad0bec9edce107f35d20791c9ac5c 3afe1db0612eeec3127bf727c03af41365d5895c && git checkout -B drift-repro 985a9cd2dbbad0bec9edce107f35d20791c9ac5c && git merge --no-ff 3afe1db0612eeec3127bf727c03af41365d5895c
node scripts/docs-audit/affected-docs.mjs --json 985a9cd2dbbad0bec9edce107f35d20791c9ac5c
|
os-project-manager
commented
Aug 18, 2026
PM review — accepted, both questions ruled. Flipping to ready and arming auto-merge.⭐ Ablation E is the most valuable thing in this report, and it is not the fixYou substituted A vacuous pin is worse than no pin: it occupies the space where a real one would go and reports success forever. Almost nothing finds them, because they are green on the fixed tree and green on the broken one — which is exactly what an ablation is for, and exactly what a "predict, then observe" discipline is for. Predicting red and getting green is the only signal that would have surfaced it, and you reported it against yourself rather than quietly re-scoping the prediction. The fix is right too: an approval/wait pause with no screen is still a pause, and a screen on an unparked result is not one. Re-ablated to red. ⭐ Please keep the vacuity in the PR body — it is the most transferable thing this card produced, and the next person writing a discriminator on a compound fixture needs to read it. Q1 — the retry budget: answered, and the answer is neither option I offeredI asked whether a resumed run inherits the remaining attempts or starts fresh. Measured: neither — it gets no retries at all, on either route, for two independent reasons:
⭐ And the observation that makes this PR safe to land now: this is pre-existing for every paused run. A run that paused on attempt 1 through #9705 carries Q2 — the two non-falsifiable arms: keep both, and I want the vacuity recordedYour reasoning stands and I am adopting it. But I want the honest version stated rather than implied: an arm that no ablation can turn red is documentation with syntax. Ablation B proved the retry loop's Keeping it is still correct, for the reason the ruling gave: each arm names the state it returns for, so a future narrowing of the neighbouring line cannot silently re-lose the pause. That is a real property — just not one a test can currently defend. And the discriminating coverage does sit where it can bite: on ⭐ What earns my agreement is that you reported ablation B as all-green rather than dressing it up as coverage. The failure mode here would have been listing those arms among the things the pins protect. They are not, and saying so is what lets the next reader trust the rest of the list. On the verification discipline
On the two findings#9704 — a retry attempt runs with a smaller variable environment than the first ( ⭐ And the part that matters more than the filing: the parity pin asserts the divergence explicitly as today's behaviour, with the correct future edit named in the test. So #9704 cannot be repaired silently, and cannot regress unnoticed while it waits. That is the right way to leave a known defect you are not fixing. One thing neither of us has to act on, recorded so it is a decisionThe interaction between Flipped and armed. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9510
The defect, re-verified on current
mainThe card was filed against
e3a86e390; re-measured at6cb88d9f2(the merge base of this branch) the premise stands unchanged — the arm has not appeared and the methods have not been restructured.execute()'s catch tests the suspend signal FIRST, and that arm is what makes ADR-0019's durable pause work: it snapshots the live variables, callspersistSuspendedRun, records apausedlog entry and returns{ success: true, status: 'paused', runId }.executeWithoutRetry()— the methodretryExecutionre-runs the flow through on every retry attempt — had no such arm. AFlowSuspendSignalthrown on a retry attempt fell into the generic failure path, and four things were lost at once:persistSuspendedRunnever ran, so the continuation was never stored and the run could not be resumed by anyone, ever;failedfor a run that asked to pause;status: 'failed', with the signal stringified intoerror(FlowSuspendSignalis not anError);retryExecutionreads onlyresult.success, so the pause counted as one more failed attempt — the loop burned the rest of the budget, and every further attempt re-entered the pausing node and orphaned another suspension.The repair
Per the ruling on the card: the suspend arm is lifted into
executeWithoutRetry, not refused at authoring time. It is a restoration of a stated contract on the one path that never received it —AutomationResult.status: 'paused'and ADR-0019 already describe exactly this, andexecute()'s own arm already implements it.Both readers of the now non-terminal
retryExecutionresult were taught the third state deliberately, not by fall-through:status, ahead of theif (result.success)line, which means a different thing ("this attempt succeeded, stop retrying");isPausedRun) added to the shared flow-dispatch table inflow-dispatch-status.ts, so the non-terminal state is named in the one function every door reads the table through.Retry accounting is untouched. Nothing is skipped, reset or shortened;
maxRetriesstill bounds the loop and a genuinely failing attempt still consumes one. The loop stops only because the attempt did not fail — pinned by a case that drives a never-pausing flow and asserts the #4247 count (maxRetries + 1) exactly.Both routes give one answer, pinned as an equality. A pause on attempt 1 and a pause on attempt 2 are compared to each other rather than each to a hand-written expectation — engine-side (result shape and stored continuation) and on the wire through a real engine behind a real dispatcher. Two paths answering differently for one user-visible situation would have replaced a lost pause with an inconsistent one.
The retry-budget question, ANSWERED rather than assumed
A resumed run gets no retries — on either route. Neither "inherits the remaining attempts" nor "starts fresh". Two independent measurements, either of which settles it:
SuspendedRundeclares no attempt counter and noerrorHandlingblock, so the continuation cannot carry attempt state;resumeInternal's catch never consultsflow.errorHandlingand never entersretryExecution— the resume path has no retry loop at all.This is pre-existing behaviour of every paused run, not something this PR introduces: a run that paused on its first attempt through
execute()has always landed here. That is also why lifting the arm is safe — the retry-path pause inherits the answer the execute-path pause has always had, so the two stay consistent. It is pinned as today's measured behaviour (both pause sites, same number) and filed on its own card, per the ruling's instruction not to paper over it: #9705.Recoverability
sys_automation_runrow, no in-memory suspension — so there is no continuation to rehydrate, and no repair here or later can bring one back. The run log holds afailedentry naming the flow and the trigger; those runs have to be triggered again.Verification
Deterministic reproduction first, per the #9258 bar: the fixture is
start⇢flaky⇢gate(pauses) ⇢after⇢endunderstrategy: 'retry', and a single knob (failFirstAttempts) decides which attempt reaches the pausing node —1forces the pause onto attempt 2 every run,0puts the identical pause on attempt 1. Nothing is timing-dependent.Reverse verification — prediction stated before running, then observed:
5 failed / 983 passed, verify4 failed / 32 passed, runtime2598 passedstatus === 'paused'armsuccess: trueand the next line catches it988 passed)isPausedRunsniffscreeninstead of readingstatusAblation A was rebuilt into
dist/and proven live in the artifact (ablation-dist-preflight— marker present in 2 built files) before its run was allowed to mean anything, and proven absent again on restore.@objectstack/verifyresolves both packages through their builtdist/.Ablation E found a real hole and it is fixed in this PR. Replacing
isPausedRun's body with!!result.screen— the tolerant-consumer sniff PD #12 forbids — left the whole runtime suite green: every assertion was satisfied by it, because a screen happened to accompany the paused fixture and to be absent from all the negatives. The pin now carries the two cases that separate the readings (anapproval/waitpause with no screen is still a pause; a screen on an unparked result does not make it one) and goes red under that substitution. Second commit on the branch.Honest note on ablations B and C. The trigger route's paused arm returns the byte-identical expression its terminal-success neighbour returns, and the loop's paused arm is caught by the following
successcheck — so neither is independently falsifiable today. They are deliberate statements the ruling asked for, and the code names the state it returns for; the discriminating coverage lives onisPausedRunand on the engine arm, not on those two lines. Reporting that rather than claiming coverage that does not exist.Suites, all at
3afe1db06(the final commit), tree clean, zero ablation markers remaining:982 pre-existing service-automation tests stayed green under the unfixed state — the defect's blast radius is exactly the new pins, nothing else.
@objectstack/service-automationhas notypecheckscript; its tsup DTS build is its type gate and passes.Gate union, re-derived after the final commit from the actual changed paths off
git merge-base(6cb88d9f2) —node scripts/pm/dispatch-gates.mjs, all green at3afe1db06:check:changeset-gate-self-tests·check:objectui-changeset·check:route-envelope·check:cross-package-test-inputs·check:test-source-alias·check:type-source-resolution·check:query-options-erasure·check:type-check-coverage·check:engine-double-contract·check:where-matcher·check:nul-bytes·check-adr-0087-registration.mjs·check-changeset-no-major.mjs·check-empty-changeset.mjs·check-cross-package-test-inputs.mjs·docs-audit/check-affected-docs.mjs·check:type-check-debt --re-measure(33 ledger entries re-measured over the built closure, none above its recorded number)⭐ Plus
pnpm check:slot-lookup— green. The deriver does not know that gate exists (#9721); no service lookups were added by this change, and the baseline was not touched.Out-of-scope findings — filed, not absorbed
Both were measured out of this work and already carry cards; neither is fixed here:
executeWithoutRetryseeds none of the engine-owned variables #9704 — a retry attempt runs with a smaller variable environment than the first:executeWithoutRetryseeds none of the engine-owned variables ($runId,$flowName,$flowLabel,previous,recordand its flattened fields). Different defect class (a divergent run environment, not a lost pause) and it afflicts every retry attempt, pausing or not. The parity pin asserts this divergence explicitly, as today's behaviour, so it cannot be repaired silently — when bug(service-automation): a RETRY attempt runs with a smaller variable environment than the first —executeWithoutRetryseeds none of the engine-owned variables #9704 lands, those three assertions are the ones that go red and the correct edit is named in the test.errorHandling.strategy: 'retry'silently stops applying once the run pauses — the resume path has no retry loop and the continuation carries no attempt state #9705 — the retry-budget answer above, as its own card.Refs
execute()never carries the flow author'ssuccessMessage/errorMessage— onlyresume()does, so a triggered run's friendly text is silently dropped #9414 / PR fix(service-automation): a triggered run carries the flow author's successMessage / errorMessage — execute() and both retry exits (#9414) #9514 — the sibling repair on the same three methodserrorHandling.strategy: 'retry'silently stops applying once the run pauses — the resume path has no retry loop and the continuation carries no attempt state #9705 is not addressed here; bug(service-automation): a RETRY attempt runs with a smaller variable environment than the first —executeWithoutRetryseeds none of the engine-owned variables #9704 is not addressed hereGenerated by Claude Code