Skip to content

Commit b030055

Browse files
os-warrenclaude
andauthored
fix(service-automation): seed a retry attempt's variable environment through the same chokepoint as attempt 1 (#9888)
* fix(service-automation): seed a retry attempt's variable environment 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 * chore(changeset): patch for the retry-attempt variable-environment repair Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent be49304 commit b030055

3 files changed

Lines changed: 166 additions & 54 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation): a retry attempt now runs with the same variable environment as the first
6+
7+
`executeWithoutRetry()` — the method the retry loop re-runs a flow through on every
8+
attempt — seeded only the flow's declared variables and `$record`, while the first
9+
attempt also binds `record` plus the triggering record's flattened fields, `previous`,
10+
`$runId`, `$flowName` and `$flowLabel`. Every retry attempt therefore ran in a strictly
11+
smaller environment than attempt 1.
12+
13+
Because conditions are strict CEL, where reading an unbound name aborts the predicate
14+
rather than yielding `false`, this was user-visible exactly where retry is most used —
15+
`errorHandling.strategy: 'retry'` on a record-change flow:
16+
17+
- a start condition or edge predicate reading `previous` (the create-vs-update
18+
discriminator) aborted on the retry, so the retry failed for a reason the first attempt
19+
never hit — reading as a flaky flow rather than a defect;
20+
- a bare reference to a triggering-record field (`status`, `budget`) aborted for the same
21+
reason;
22+
- a pausing node (e.g. Approval) reached on a retry attempt saw no `$runId`, so the
23+
external state it minted could not be mapped back to the run for resume (ADR-0019).
24+
25+
Both methods now seed through one shared chokepoint. First-attempt behaviour is unchanged.

packages/services/service-automation/src/engine.ts

Lines changed: 96 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3077,38 +3077,19 @@ export class AutomationEngine implements IAutomationService {
30773077
// runaway it exists to stop.
30783078
let reentryHeld = false;
30793079

3080-
// Initialize variable context
3081-
const variables = this.seedDeclaredVariables(flow, context);
3082-
// Inject trigger record. `$record` is the canonical handle; `record` is a
3083-
// friendlier alias so templates/conditions can write `{record.title}` and
3084-
// `record.status`. We also flatten the record's own fields to top-level
3085-
// variables (so bare references like `status`/`budget` resolve in start
3086-
// conditions and edge predicates) WITHOUT clobbering flow inputs already
3087-
// seeded above. `previous` exposes the pre-update row for transition gates.
3088-
if (context?.record) {
3089-
variables.set('$record', context.record);
3090-
variables.set('record', context.record);
3091-
for (const [k, v] of Object.entries(context.record)) {
3092-
if (!variables.has(k)) variables.set(k, v);
3093-
}
3094-
}
3095-
// Always bind `previous` — to `null` on the create/insert leg (there is no
3096-
// prior row) — so a start condition can DISCRIMINATE create vs update on a
3097-
// `record-after-write` flow: `previous == null` is the create leg (#3427).
3098-
// Binding only-when-truthy left `previous` an unknown CEL variable on
3099-
// insert, so ANY reference to it (even `previous == null`) threw
3100-
// "Unknown variable: previous" and failed the whole condition.
3101-
variables.set('previous', context?.previous ?? null);
3102-
3080+
// Initialize the run's variable environment. Every binding a run is
3081+
// entitled to — the flow's declared variables, the trigger record and
3082+
// its flattened fields, `previous`, and the engine-owned `$runId` /
3083+
// `$flowName` / `$flowLabel` — is seeded by `seedRunVariables`, the one
3084+
// chokepoint `executeWithoutRetry` shares (#9704). See that helper for
3085+
// why the seeding is not written out here any more.
3086+
//
3087+
// The run id is minted BEFORE the seeding because `$runId` is part of
3088+
// that environment. Order-safe: `nextRunId()` is a stateless random id
3089+
// (no counter to advance), and nothing between the old call site and
3090+
// this one reads or mints one.
31033091
const runId = this.nextRunId();
3104-
// Expose the run id to executors (ADR-0019): a pausing node (e.g. Approval)
3105-
// reads `$runId` to map its external state back to this run for resume.
3106-
variables.set('$runId', runId);
3107-
// Expose flow identity to executors so externalized state (e.g. an
3108-
// approval request row) can carry a human-readable origin. Captured in
3109-
// the variable snapshot, so still present after a suspend/resume.
3110-
variables.set('$flowName', flowName);
3111-
variables.set('$flowLabel', flow.label ?? flowName);
3092+
const variables = this.seedRunVariables(flow, flowName, context, runId);
31123093
const startedAt = new Date().toISOString();
31133094
const steps: StepLogEntry[] = [];
31143095

@@ -6315,6 +6296,78 @@ export class AutomationEngine implements IAutomationService {
63156296
return variables;
63166297
}
63176298

6299+
/**
6300+
* Seed a run's COMPLETE variable environment — the one chokepoint every
6301+
* attempt of every run goes through (#9704).
6302+
*
6303+
* `seedDeclaredVariables` above turns the flow's own DECLARATIONS into
6304+
* bindings; this adds everything the ENGINE owns on top of them, in the
6305+
* order the precedence rules require:
6306+
*
6307+
* 1. declared variables (params, then `defaultValue`) — seeded first, so
6308+
* the record flattening below cannot shadow a flow input;
6309+
* 2. the trigger record: `$record` is the canonical handle, `record` a
6310+
* friendlier alias so templates and conditions can write
6311+
* `{record.title}` / `record.status`, plus the record's own fields
6312+
* flattened to top-level names so bare references (`status`, `budget`)
6313+
* resolve in start conditions and edge predicates — WITHOUT clobbering
6314+
* anything already bound;
6315+
* 3. `previous`, bound ALWAYS — to `null` on the create/insert leg, since
6316+
* there is no prior row — so a start condition can DISCRIMINATE create
6317+
* from update on a `record-after-write` flow: `previous == null` is the
6318+
* create leg (#3427). Binding it only when truthy left `previous` an
6319+
* unknown CEL variable on insert, so ANY reference to it (even
6320+
* `previous == null`) threw "Unknown variable: previous" and failed the
6321+
* whole condition;
6322+
* 4. `$runId`, so a pausing node (e.g. Approval) can map its external
6323+
* state back to this run for resume (ADR-0019), and `$flowName` /
6324+
* `$flowLabel`, so externalized state carries a human-readable origin.
6325+
* All three are captured in the variable snapshot, so they survive a
6326+
* suspend/resume.
6327+
*
6328+
* ⚠️ It is ONE method because the two callers drifting apart is the defect
6329+
* it repairs, not a tidiness preference. `execute()` seeded all of the
6330+
* above and `executeWithoutRetry()` — the method `retryExecution` re-runs
6331+
* the flow through on EVERY retry attempt — seeded only (1) and `$record`,
6332+
* so a retry attempt ran in a strictly smaller environment than the first
6333+
* one: conditions are strict CEL, where reading an unbound name ABORTS the
6334+
* predicate instead of yielding `false` (#4697), so the retry failed for a
6335+
* reason attempt 1 never hit, which reads as a flaky flow rather than a
6336+
* defect. The two methods had already drifted once per card on four
6337+
* separate exits (#9378, #9415, #9414, #9510) before this one, always in
6338+
* the same direction — the copy that is not `execute()` is the one a repair
6339+
* forgets. `buildRunTrigger` is the same chokepoint pattern, for the same
6340+
* reason. ⛔ So a change here belongs here: re-inlining either caller's copy
6341+
* re-opens the drift, and `retry-attempt-pause.test.ts` pins the two
6342+
* snapshots against EACH OTHER precisely so it cannot happen silently.
6343+
*
6344+
* The caller mints `runId` and passes it in rather than this helper minting
6345+
* one, because the run id is the caller's own bookkeeping: it keys the log
6346+
* row, the continuation and the returned envelope, and a helper that
6347+
* produced a second one would put a `$runId` in the snapshot that names no
6348+
* run anybody can resume.
6349+
*/
6350+
private seedRunVariables(
6351+
flow: FlowParsed,
6352+
flowName: string,
6353+
context: AutomationContext | undefined,
6354+
runId: string,
6355+
): Map<string, unknown> {
6356+
const variables = this.seedDeclaredVariables(flow, context);
6357+
if (context?.record) {
6358+
variables.set('$record', context.record);
6359+
variables.set('record', context.record);
6360+
for (const [k, v] of Object.entries(context.record)) {
6361+
if (!variables.has(k)) variables.set(k, v);
6362+
}
6363+
}
6364+
variables.set('previous', context?.previous ?? null);
6365+
variables.set('$runId', runId);
6366+
variables.set('$flowName', flowName);
6367+
variables.set('$flowLabel', flow.label ?? flowName);
6368+
return variables;
6369+
}
6370+
63186371
/**
63196372
* Execute a flow without triggering retry logic (used by retryExecution to prevent recursion).
63206373
*
@@ -6351,12 +6404,19 @@ export class AutomationEngine implements IAutomationService {
63516404
return { success: false, code: 'FLOW_DISABLED', error: `Flow '${flowName}' is disabled` };
63526405
}
63536406

6354-
const variables = this.seedDeclaredVariables(flow, context);
6355-
if (context?.record) {
6356-
variables.set('$record', context.record);
6357-
}
6358-
6407+
// [#9704] The SAME environment attempt 1 runs in — seeded through the
6408+
// same chokepoint `execute()` uses. This method used to seed only the
6409+
// declared variables and `$record`, so every retry attempt ran in a
6410+
// strictly smaller environment than the first: `record` and its
6411+
// flattened fields, `previous`, `$runId`, `$flowName` and `$flowLabel`
6412+
// were all absent. Under strict CEL an unbound name ABORTS the
6413+
// predicate rather than yielding false (#4697), so a start condition or
6414+
// edge predicate reading `previous` (#3427) or a bare record field
6415+
// failed on the retry for a reason attempt 1 never hit — a flaky flow,
6416+
// to its author — and a pausing node on a retry attempt had no `$runId`
6417+
// to map its external state back to this run with (ADR-0019).
63596418
const runId = this.nextRunId();
6419+
const variables = this.seedRunVariables(flow, flowName, context, runId);
63606420
const startedAt = new Date().toISOString();
63616421
const steps: StepLogEntry[] = [];
63626422

packages/services/service-automation/src/retry-attempt-pause.test.ts

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -273,26 +273,53 @@ describe('#9510 — a pause on a RETRY attempt is durable, not a burned attempt'
273273
expect(storedViaRetry?.nodeType).toBe(storedViaExecute?.nodeType);
274274
expect(storedViaRetry?.correlation).toBe(storedViaExecute?.correlation);
275275

276-
// ⚠️ The one thing that does NOT match, asserted rather than skirted.
277-
// `executeWithoutRetry` seeds none of the engine-owned variables
276+
// ⭐ [#9704] The variable ENVIRONMENT matches too — and this block is
277+
// where the divergence used to be pinned as measured behaviour.
278+
// `executeWithoutRetry` seeded none of the engine-owned variables
278279
// `execute()` binds (`$runId`, `$flowName`, `$flowLabel`, `record` and
279-
// its flattened fields, `previous`), so a retry attempt has always run
280-
// in a smaller environment than the first — filed as #9704, a divergent
281-
// run environment rather than a lost pause, and out of scope here: it
282-
// afflicts every retry attempt, pausing or not, and predates this card.
280+
// its flattened fields, `previous`), so a retry attempt ran in a
281+
// strictly SMALLER environment than the first: under strict CEL an
282+
// unbound name ABORTS a predicate instead of yielding false (#4697), so
283+
// a start condition or edge predicate reading `previous` (#3427) or a
284+
// bare record field failed on the retry for a reason attempt 1 never
285+
// hit, and a pausing node on a retry attempt saw no `$runId` to map its
286+
// external state back with (ADR-0019). Both methods now seed through
287+
// one helper (`seedRunVariables`), so the two snapshots are compared to
288+
// EACH OTHER — the same discipline the result envelope above uses.
283289
//
284-
// It is pinned as TODAY's measured behaviour, deliberately, so #9704
285-
// cannot be repaired silently. When it is repaired these three
286-
// assertions are the ones that go red, and the correct edit is to
287-
// delete them and add `variables` to the parity block above.
288-
const engineOwned = ['$runId', '$flowName', '$flowLabel', 'previous', 'record'];
289-
for (const name of engineOwned) {
290-
expect(Object.keys(storedViaExecute?.variables ?? {})).toContain(name);
291-
expect(Object.keys(storedViaRetry?.variables ?? {})).not.toContain(name);
292-
}
293-
// What the two DO share: the run's own work. Both snapshots carry the
294-
// pausing node's inputs, so the continuation is a real continuation on
295-
// either route — the half this card is about.
290+
// `$runId` is per-run by nature, so it is asserted against the run id
291+
// each route actually returned and then normalized for the comparison.
292+
// Asserting it by VALUE is the point: a snapshot merely *carrying* a
293+
// `$runId` that names a different run is the ADR-0019 mapping hole this
294+
// card is about, and a presence-only check cannot see it.
295+
expect(storedViaRetry?.variables?.$runId).toBe(viaRetry.runId);
296+
expect(storedViaExecute?.variables?.$runId).toBe(viaExecute.runId);
297+
const vars = (s: { variables: Record<string, unknown> } | null) => ({
298+
...(s?.variables ?? {}),
299+
$runId: '<runId>',
300+
});
301+
expect(vars(storedViaRetry)).toEqual(vars(storedViaExecute));
302+
303+
// …and the engine-owned bindings pinned by VALUE on the RETRY route,
304+
// not merely by parity: the comparison above is equally satisfied if
305+
// BOTH routes lose them, which is the shape a later "simplification" of
306+
// the shared helper would take.
307+
expect(storedViaRetry?.variables?.$flowName).toBe('flaky_approval');
308+
expect(storedViaRetry?.variables?.$flowLabel).toBe('flaky_approval');
309+
// `previous` is bound ALWAYS — to `null` on the create leg, since that
310+
// is what lets a start condition discriminate create vs update (#3427).
311+
// `toHaveProperty` rather than a `?.previous` read: the defect was the
312+
// key being ABSENT, and absent and `null` both read as `null`.
313+
expect(storedViaRetry?.variables).toHaveProperty('previous', null);
314+
expect(storedViaRetry?.variables?.record).toEqual({ id: 'ord_9510', amount: 500 });
315+
// The trigger record's own fields flattened to top-level names — what
316+
// makes a bare `amount` reference resolve on a retry attempt.
317+
expect(storedViaRetry?.variables?.amount).toBe(500);
318+
expect(storedViaRetry?.variables?.id).toBe('ord_9510');
319+
320+
// What the two shared even BEFORE the repair: the run's own work. Both
321+
// snapshots carry the pausing node's inputs, so the continuation is a
322+
// real continuation on either route — the half #9510 was about.
296323
expect(storedViaRetry?.variables?.['flaky.ok']).toEqual(storedViaExecute?.variables?.['flaky.ok']);
297324
expect(storedViaRetry?.variables?.$record).toEqual(storedViaExecute?.variables?.$record);
298325

0 commit comments

Comments
 (0)