Skip to content

feat(core): node-retry budget above the fallback chain (1.S, ADR-0040)#24

Merged
cemililik merged 8 commits into
mainfrom
development
Jun 15, 2026
Merged

feat(core): node-retry budget above the fallback chain (1.S, ADR-0040)#24
cemililik merged 8 commits into
mainfrom
development

Conversation

@cemililik

@cemililik cemililik commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Lands 1.S — node-level retry above the provider fallback chain (the 1.m4 layer error-handling.md / run-plan.md / node-types.md already referenced), per ADR-0040 (Accepted; amends one wiring detail of ADR-0038). Engine-only (@relavium/core); 645 tests green, format:check + lint + typecheck + build clean, Leakwatch clean. Put through two adversarially-verified multi-agent review passes (Sonnet → 17 findings fixed; Opus → 3 fixed, incl. a real backoff-overflow hardening).

What it does (ADR-0040 Part A)

The run loop wraps each vertex in a retry loop (#dispatch): a failed outcome that is retryable, within budget (retry.max total attempts), and admitted by retry_on emits a non-terminal node:retrying, sleeps the backoff via an abort-aware host.setTimer (a cancel or a sibling-failure abort disarms it → cancel wins), and re-dispatches with an incremented attemptNumber. The vertex stays running across the loop (slot held, the run never idles mid-retry). node:failed/node:completed stay the terminals (failure only on exhaustion / fatal / retry_on-excluded), both carrying attemptNumber on a retry. Applies to agent / condition / transform / merge nodes.

  • node.retry is now the above-chain budget — the AgentRunner no longer feeds it into the primary FallbackPlanEntry (maxAttempts: 1). This reverses one detail of ADR-0038 (dated amendment + backlink added there); the rest stands. #retryConfig: node.retry ?? agent.retry for agents; node.retry for condition/transform/merge.
  • Backoff: linear (base·n) / exponential (base·2^(n-1)), default base 1000 ms, no jitter (deterministic), clamped to a 24h ceiling so a large max can never overflow delayMs.
  • retry_on is parse-restricted to RETRYABLE_ERROR_CODES (provider_rate_limit / provider_unavailable / tool_failed / sandbox_error) — a fatal code is rejected at parse, never silently ignored.

Contract (all additive, @relavium/shared)

RetrySchema gains backoff_ms? + retry_on?; new RETRYABLE_ERROR_CODES; retry added to the condition/transform/merge node schemas; new non-terminal node:retrying event; optional attemptNumber on node:started/node:completed/node:failed. The 1.R Checkpointer fold is unchangednode:retrying is non-state-bearing and node:failed stays terminal.

Deferred: retry-from-node (ADR-0040 Part B) → Phase-2

Re-running a settled run from a node on the same runId (to dedup completed-upstream side effects) would append a second terminal event, breaking exactly-one-terminal (ADR-0036) + the 1.R fold; a new runId keeps one terminal but loses upstream dedup. Reconciling both needs the Phase-2 persistent store + a run-attempt model (which already owns the surface trigger). Captured as a dated amendment in ADR-0040 + a deferred-tasks.md entry. Part A is the roadmap §1.S primary acceptance ("a node whose chain is exhausted retries per its budget then fails; a transient failure recovers within budget; cost per attempt").

Tests

recover-within-budget (node:retrying→re-dispatch→completed with attemptNumber===2); budget-exhausted→node:failed{attemptNumber}; fatal→no retry; retry_on-excluded→no retry; cancel-during-backoff wins; sibling-fatal-during-backoff abandons the re-dispatch; no-budget transient terminal; exponential + default-base delays [1000,2000,4000]; the 24h backoff clamp [50M, 86.4M]; an agent-node retry e2e via the real AgentRunner proving the agent.retry fallback (A.8); checkpoint node:retrying fold; shared schema accept/reject (retry on the three node types, retry_on subset + empty rejected, node:retrying missing delayMs rejected).

Review trail

  • Round 1 (Sonnet, 4 lenses): 17 findings — headline: node:completed didn't carry attemptNumber on a retry recovery (symmetric gap vs node:failed); + canonical-doc + test-depth items.
  • Round 2 (Opus, 4 lenses): 3 findings — headline: unbounded retry.max × exponential backoff overflowed delayMs (zombie timer / Infinity-stamp-throw → unhandled rejection); fixed by the 24h clamp. + the agent-retry-path coverage gap.

Refs: ADR-0040, ADR-0038, ADR-0011, ADR-0036

🤖 Generated with Claude Code

Summary by Sourcery

Introduce an engine-level node retry budget above the provider fallback chain and associated runtime events, applying to agents and non-agent nodes, while keeping existing run semantics and checkpointing intact.

New Features:

  • Add configurable above-chain retry budgets to agent, condition, transform, and merge nodes, including backoff strategy, optional base delay, and retry-on error-code filtering.
  • Emit a new non-terminal node:retrying run event and track per-attempt attemptNumber on node start, completion, and failure events to surface node-level retry attempts.

Enhancements:

  • Refactor the workflow engine run loop to dispatch nodes through a retry-aware loop that uses abortable backoff sleeps and respects cancellation and sibling failures.
  • Change agent LLM fallback planning so node.retry no longer drives within-chain primary retries, reserving it for engine-level node retries, and keep primary entries to a single attempt by default.
  • Ensure node retry integrates cleanly with checkpoint reconstruction by treating node:retrying as non-state-bearing while preserving terminal node semantics.

Documentation:

  • Update agent and workflow YAML specs, SSE event schema, and related ADRs to redefine retry as an above-chain node retry budget, document the new node:retrying event, and clarify its relationship to provider fallback.
  • Add ADR-0040 to capture the node-level retry design and amend ADR-0038, and extend the roadmap with deferred retry-from-node follow-up work.

Tests:

  • Add engine tests covering node retry behavior, backoff strategies, 24h backoff clamping, cancellation and sibling-failure interactions, and no-budget behavior.
  • Extend agent runner tests to assert that node.retry no longer alters within-chain primary retries and that agent-level retry budgets are consumed by the engine.
  • Expand shared schema and run-event tests for retry configuration validation, retryable error-code subsets, the new node:retrying event shape, and its non-state-bearing checkpoint fold.

Summary by CodeRabbit

  • New Features

    • Introduced node-level retry budget with configurable max attempts and backoff strategies (linear/exponential).
    • Added node:retrying event to track retry attempts and delays during execution.
    • Extended retry configuration support to condition, transform, and merge nodes.
    • Added retry_on filtering to control which error codes trigger retries.
  • Documentation

    • Updated YAML specifications and event schema documentation to reflect new retry semantics.
    • Added architectural decision records (ADRs) and roadmap notes.
  • Tests

    • Added comprehensive end-to-end and unit tests validating retry behavior, backoff timing, and error handling.

cemililik and others added 5 commits June 15, 2026 12:29
…n (1.S)

Accept ADR-0040 defining workstream 1.S: an engine-level node-retry budget above
the provider fallback chain, re-interpreting node.retry as that above-chain budget
(amending one wiring detail of ADR-0038), with a new non-terminal node:retrying
event so node:failed stays terminal and the 1.R Checkpointer fold is untouched.

Authored + adversarially self-reviewed in two rounds (the second a 3-lens Sonnet
workflow); folded every confirmed finding — which node schemas gain `retry`
(condition/transform/merge join agent; human_gate/input/output/parallel excluded),
the concrete linear/exponential backoff formula, the RETRYABLE_ERROR_CODES subset
(no spurious 5th code), the setTimer+disarm abort-aware sleep (no AbortSignal param),
node:retrying.error shape (no correlationId), the retry-count-resets-on-crash
clarification, and the full @relavium/shared land-time obligation list.

- ADR-0040 Accepted; registered in the decisions index.
- ADR-0038 carries a dated "Amended by ADR-0040" note + backlink (append-only;
  the primary-entry retry/backoff wiring is the only reversed detail).

Refs: ADR-0040, ADR-0038, ADR-0011
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The above-chain node-retry layer: the run loop re-dispatches a whole node on a
retryable failure, up to its budget, with backoff — the layer error-handling.md /
run-plan.md / node-types.md already referenced. Re-interprets node.retry as this
budget (amending ADR-0038's primary-entry wiring).

@relavium/shared (additive):
- RetrySchema gains backoff_ms? + retry_on? (restricted to RETRYABLE_ERROR_CODES via
  a subset z.enum — a non-retryable / empty retry_on is rejected at parse, ADR-0040 A.4),
  re-documented as the above-chain budget; max = total attempts incl. the first.
- RETRYABLE_ERROR_CODES constant (provider_rate_limit / provider_unavailable /
  tool_failed / sandbox_error).
- `retry` added to the condition / transform / merge node schemas (joining agent).
- New non-terminal `node:retrying` event (RunEventUnionSchema + RUN_EVENT_TYPES +
  type export + matrix); optional attemptNumber on node:started / node:failed.

@relavium/core engine:
- #dispatch wraps each vertex in its retry loop: a retryable-within-budget-and-
  retry_on-admitted failure emits node:retrying, sleeps the backoff via an
  abort-aware host.setTimer (cancel disarms it → cancel wins), and re-dispatches with
  an incremented attemptNumber; the vertex stays `running` across the loop (slot held,
  run never idles mid-retry). #settleFailed (terminal node:failed) runs only on
  exhaustion / a fatal / a retry_on-excluded failure. #retryConfig resolves the budget
  (agent: node.retry ?? agent.retry; condition/transform/fan_in: node.retry). Linear
  (base*n) / exponential (base*2^(n-1)) backoff, default base 1000 ms, no jitter.
- AgentRunner no longer feeds node.retry into the primary FallbackPlanEntry
  (maxAttempts: 1) — node.retry is the engine's above-chain budget now (ADR-0040
  amends ADR-0038, noted in code).
- checkpoint.ts: node:retrying folded as non-state-bearing (no 1.R fold change).

Tests (+7): recover-within-budget (node:retrying → re-dispatch → completed),
budget-exhausted → node:failed{attemptNumber}, fatal → no retry, retry_on-excluded →
no retry, cancel-during-backoff wins, no-budget transient is terminal; the agent-runner
within-chain-retry test inverted to the ADR-0040 semantics; +2 shared schema tests.

Docs: sse-event-schema (node:retrying + attemptNumber), agent-yaml-spec (retry is the
above-chain budget, the "same model" language corrected), agent.ts RetrySchema comment.

retry-from-node (ADR-0040 Part B) lands next.

Refs: ADR-0040, ADR-0038, ADR-0011
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implementing Part B surfaced an irreducible Phase-1 conflict: re-running a settled
run on the SAME runId (to dedup completed-upstream side effects via the
runId+nodeId+retryCount key) would append a SECOND terminal event, breaking the
exactly-one-terminal invariant (ADR-0036) and the 1.R Checkpointer fold; a NEW runId
keeps one terminal but loses upstream dedup. Reconciling both needs the real
persistent store + a run-attempt model — Phase-2 (which already owns the surface).

- ADR-0040 Part B: dated "Amended — deferred to Phase-2" note (append-only); the
  original design intent preserved below it.
- deferred-tasks.md: a "Node retry (1.S) follow-ups" entry tracking retry-from-node.

The in-run node-retry budget (Part A) is the landed 1.S deliverable and satisfies
the roadmap §1.S primary acceptance.

Refs: ADR-0040, ADR-0036
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarially-verified review of the 1.S diff (17/18 survived). Fold the confirmed
items.

Correctness:
- node:completed now carries attemptNumber on a retry-recovered completion (the
  symmetric gap vs node:failed): #onOutcome forwards attemptNumber to #settleCompleted,
  which spreads it when >1. Closes the schema/doc contract that already declared it.
- Clarify the #abortableSleep `!sleptFully` comment: the path fires for a cancel OR a
  sibling node's failure (both abort) — #settleFailed honours precedence either way.

Docs (canonical homes):
- workflow-yaml-spec.md: `retry` added to the condition/transform/merge rows + a note
  (the authored-node surface; the field shape stays in agent-yaml-spec).
- agent-yaml-spec.md: enumerate the valid retry_on codes (provider_rate_limit /
  provider_unavailable / tool_failed / sandbox_error; a non-retryable code is parse-rejected).
- ADR-0040: correct the retry_on mechanism wording (z.enum(RETRYABLE_ERROR_CODES) subset
  + .min(1), not a superRefine) to match the implementation.

Tests (+3, +1 shared):
- node:completed.attemptNumber===2 asserted in the recover-within-budget test.
- exponential backoff + the default 1000ms base (backoff_ms omitted) → delays
  [1000, 2000, 4000] (covers the previously-untested exponential formula + default).
- a sibling fatal failure during a retry backoff abandons the re-dispatch → run:failed
  with the sibling's root cause, no timer left armed.
- checkpoint fold: a node:started→node:retrying→node:started→node:completed stream folds
  to `completed` (node:retrying non-state-bearing).
- run-event: a node:retrying missing delayMs is rejected.
- fireBackoff test helper bounded (fails fast instead of hanging if a timer never arms);
  cancel-during-backoff test comment corrected (abort short-circuits before arming).

Skipped: the "docs: defer…" commit-scope nit (7f6862e already made; not worth a history
rewrite — a cross-cutting docs commit, future commits scope per package).

Refs: ADR-0040, ADR-0036
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pus review)

Round-2 Opus review of the 1.S diff (3/7 confirmed; no blockers/highs). Fold the
confirmed items:

- **Backoff overflow (the sharp one).** retry.max is an unbounded positiveInt and
  #backoffMs computed `base * 2^(attempt-1)` with no cap. A large (schema-valid) max
  drove delayMs to ~4.5e18 (a ~143M-year one-shot timer → a zombie run) and, past
  attempt ~1023, to Infinity — which RunEventSchema.parse rejects at stamp time, and
  since node:retrying is emitted from the fire-and-forget #dispatch the throw became an
  unhandled rejection leaving the run unsettled. Fixed: #backoffMs now clamps to
  MAX_NODE_RETRY_BACKOFF_MS (24h) via Math.min, so delayMs is always a finite, in-range
  nonNegativeInt. Test: base 50M exponential → [50M, 86.4M] (the second clamped).
- **Cancel/abort just past a fully-elapsed backoff.** Added a `#cancelling ||
  signal.aborted` short-circuit after the sleep so a cancel landing on the same tick
  the timer elapsed settles the node instead of wasting a re-dispatch.
- **Agent-retry path untested.** New agent-runner.e2e test: an agent node with only
  `agent.retry` (no node.retry) + a provider that errors retryably then succeeds — the
  engine re-dispatches once and completes, proving #retryConfig's `node.retry ??
  agent.retry` fallback (ADR-0040 A.8) end-to-end through the real AgentRunner.

(Refuted/skipped: 4 round-2 findings did not survive verification.)

Refs: ADR-0040
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@cemililik, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 3 hours, 32 minutes, and 18 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9cd31a5e-ef8d-43ce-8218-a02089ec736c

📥 Commits

Reviewing files that changed from the base of the PR and between e579774 and e997ccb.

📒 Files selected for processing (1)
  • packages/core/src/engine/agent-runner.e2e.test.ts
📝 Walkthrough

Walkthrough

Implements ADR-0040: an engine-level above-chain node retry budget. node.retry is decoupled from AgentRunner's primary FallbackPlanEntry (now hardcoded maxAttempts: 1) and instead drives a new #dispatch retry loop in the engine with deterministic backoff, abort-safe sleep, and a non-terminal node:retrying event. RETRYABLE_ERROR_CODES, retry_on validation, and NodeRetryingEventSchema are added to shared contracts. condition/transform/merge nodes gain the retry field. Checkpoint folding ignores node:retrying.

Changes

Node retry budget above the provider fallback chain

Layer / File(s) Summary
Shared vocabulary and event schema contracts
packages/shared/src/constants.ts, packages/shared/src/agent.ts, packages/shared/src/node.ts, packages/shared/src/run-event.ts, packages/shared/src/node.test.ts, packages/shared/src/run-event.test.ts
Adds RETRYABLE_ERROR_CODES constant and RetryableErrorCode type; extends RetrySchema with optional retry_on; adds NodeRetryingEventSchema (attemptNumber, error without correlationId, delayMs) and wires it into RunEventSchema; adds optional attemptNumber to NodeStartedEventSchema and NodeFailedEventSchema; adds retry field to ConditionNodeSchema, TransformNodeSchema, and MergeNodeSchema; registers node:retrying in RUN_EVENT_TYPES. Schema and union contract tests updated to cover 20 event types, new valid/invalid node:retrying payloads, and retry_on rejection of non-retryable codes.
AgentRunner primary fallback entry wiring change
packages/core/src/engine/agent-runner.ts, packages/core/src/engine/agent-runner.test.ts
buildPlanEntries no longer reads node.retry/agent.retry to populate the primary FallbackPlanEntry; primary entry is hardcoded to maxAttempts: 1. Unit test updated to assert a single streaming call and error.retryable = true, confirming node.retry no longer drives within-chain retries.
Engine retry dispatch loop and backoff helpers
packages/core/src/engine/engine.ts
Replaces the single-attempt #execute call with a retry-aware #dispatch(vertex, attemptNumber) loop. Adds #retryConfig, #shouldRetry (budget + retry_on/RETRYABLE_ERROR_CODES), #backoffMs (deterministic, clamped to 24 h), and #abortableSleep (cancel-wins timer). Emits non-terminal node:retrying with delayMs; keeps vertex running across backoff; re-emits node:started per re-dispatch; threads attemptNumber through #onOutcome, #settleCompleted, and #settleFailed, conditionally including it in durable events when > 1.
Checkpoint folding: node:retrying non-state-bearing
packages/core/src/engine/checkpoint.ts, packages/core/src/engine/checkpoint.test.ts
Updates applyNodeEvent default-branch comment to call out both node:started and node:retrying as intentionally non-state-bearing. New test asserts node:retrying is ignored during reconstructCheckpointState and a subsequent node:completed correctly determines the final node state.
Engine unit tests and agent-runner e2e test
packages/core/src/engine/engine.test.ts, packages/core/src/engine/agent-runner.e2e.test.ts
Adds fireBackoff and flaky test helpers. Introduces the full "node retry budget above the chain" engine test suite covering: successful recovery, linear/exponential backoff schedules, 24 h clamping, sibling-fatal abort during backoff, cancel during backoff, budget exhaustion (node:failed with attemptNumber), non-retryable and retry_on-excluded failure eligibility, spurious node:retrying suppression when run is doomed, and attempt-1 attemptNumber omission. E2e test validates agent-node retry with a scripted provider and in-memory timer.
ADR-0040, ADR-0038 amendment, and reference documentation
docs/decisions/0040-node-retry-budget-above-the-chain.md, docs/decisions/0038-agentrunner-llm-call-boundary.md, docs/decisions/README.md, docs/reference/contracts/agent-yaml-spec.md, docs/reference/contracts/sse-event-schema.md, docs/reference/contracts/workflow-yaml-spec.md, docs/roadmap/deferred-tasks.md
Adds ADR-0040 (two-layer retry model, schema extensions, engine control-flow, checkpointer implications, Part B deferral). Amends ADR-0038 with above-chain budget clarification. Updates ADR index. Expands agent-yaml-spec retry fields and retry-vs-fallback section. Extends sse-event-schema with NodeRetryingEvent, a two-attemptNumber-families section, and clarified field comments. Updates workflow-yaml-spec node table to document retry on condition/transform/merge. Records deferred retry-from-node in roadmap.

Sequence Diagram(s)

sequenceDiagram
  rect rgba(135, 206, 235, 0.5)
    note over WorkflowEngine,EventBus: Above-chain node retry loop (ADR-0040)
  end
  participant WorkflowEngine
  participant dispatch
  participant AgentRunner
  participant EventBus
  participant TimerHost

  WorkflowEngine->>dispatch: `#dispatch`(vertex, attemptNumber=1)
  dispatch->>EventBus: node:started {nodeId}
  dispatch->>AgentRunner: `#runAttempt`(vertex, attemptNumber=1)
  AgentRunner-->>dispatch: outcome {error: retryable=true, code}

  dispatch->>dispatch: `#shouldRetry`(outcome, budget, retry_on) → true
  dispatch->>dispatch: `#backoffMs`(attemptNumber, config) → delayMs
  dispatch->>EventBus: node:retrying {nodeId, attemptNumber=1, error, delayMs}
  dispatch->>TimerHost: `#abortableSleep`(delayMs)
  TimerHost-->>dispatch: timer fired (or aborted by cancel/sibling)

  alt timer fired normally
    dispatch->>EventBus: node:started {nodeId, attemptNumber=2}
    dispatch->>AgentRunner: `#runAttempt`(vertex, attemptNumber=2)
    AgentRunner-->>dispatch: outcome {completed}
    dispatch->>EventBus: node:completed {nodeId, attemptNumber=2}
  else aborted (cancel or sibling fatal)
    dispatch->>EventBus: node:failed / run:cancelled
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • HodeTech/Relavium#18: Directly modifies agent-runner.ts plan-entry retry/backoff wiring, which ADR-0040 amends by removing node.retry from the primary FallbackPlanEntry.
  • HodeTech/Relavium#22: Touches the checkpoint event-folding pipeline; this PR extends it by making node:retrying non-state-bearing in reconstructCheckpointState.
  • HodeTech/Relavium#13: Establishes the provider fallback chain runner whose FallbackPlanEntry.maxAttempts semantics are directly amended here to decouple from node.retry.

Poem

🐇 Hoppity-hop, the retry dance begins,
Above the chain where the budget wins.
First attempt fails? No panic, no fright—
node:retrying glows, then we sleep for the night.
Tick goes the timer, abort-safe and true,
Attempt two arrives and the workflow runs through! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: implementing a node-level retry budget above the provider fallback chain (1.S, ADR-0040), which is the primary focus of all file changes and testing.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch development

Comment @coderabbitai help to get the list of available commands and usage tips.

@sourcery-ai

sourcery-ai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements ADR-0040 Part A by moving node-level retry handling into the engine as an above-fallback-chain retry loop, extending shared contracts and schemas to support backoff configuration and events, and updating AgentRunner wiring and docs to reflect the new semantics while keeping checkpointing intact.

Sequence diagram for above-chain node retry loop in RunExecution.#dispatch

sequenceDiagram
  participant RunExecution
  participant NodeExecutor
  participant Host as ExecutionHost

  RunExecution->>NodeExecutor: #runAttempt(vertex, attempt=1)\n→ execute(ctx)
  NodeExecutor-->>RunExecution: NodeOutcome.failed{ error.retryable=true }
  RunExecution->>RunExecution: #shouldRetry(retry, error, attempt)
  alt within_budget_and_retryable
    RunExecution->>RunExecution: delayMs = #backoffMs(retry, attempt)
    RunExecution->>RunExecution: #emitDurable(node:retrying{attemptNumber, error, delayMs})
    RunExecution->>Host: #abortableSleep(delayMs)\n→ host.setTimer(delayMs, onFire)
    Host-->>RunExecution: sleep result (true/false)
    alt sleep_aborted_or_run_settled
      RunExecution->>RunExecution: #onOutcome(vertex, outcome, startedAtMs, attempt)
      RunExecution-->>RunExecution: emit node:failed or node:completed
    else sleep_completed
      RunExecution->>RunExecution: attempt += 1
      RunExecution->>RunExecution: #emitDurable(node:started{attemptNumber: attempt})
      RunExecution->>NodeExecutor: #runAttempt(vertex, attempt)
      NodeExecutor-->>RunExecution: NodeOutcome (failed or completed)
      RunExecution->>RunExecution: loop decision
    end
  else not_retryable_or_budget_exhausted
    RunExecution->>RunExecution: #onOutcome(vertex, outcome, startedAtMs, attempt)
    RunExecution-->>RunExecution: emit terminal node:failed or node:completed
  end
Loading

File-Level Changes

Change Details Files
Add engine-level node retry loop with backoff, new node:retrying event, and attemptNumber tracking across node events.
  • Replace direct node execution with #dispatch loop that consults per-node retry config, runs attempts via #runAttempt, and re-dispatches on retryable failures within budget.
  • Emit non-terminal node:retrying events containing attemptNumber, error, and computed delayMs, then sleep via an abort-aware #abortableSleep using the host timer before re-dispatch.
  • Extend node:started, node:completed, and node:failed events to carry optional attemptNumber; adjust #onOutcome, #settleCompleted, and #settleFailed to propagate attemptNumber and keep node:failed terminal.
  • Introduce DEFAULT_NODE_RETRY_BACKOFF_MS and MAX_NODE_RETRY_BACKOFF_MS, compute linear/exponential backoff in #backoffMs, and cap delays at 24h to prevent delayMs overflow and absurd timers.
  • Keep checkpointing semantics unchanged by treating node:retrying as non-state-bearing and ignoring it in reconstructCheckpointState while preserving exactly-one-terminal per node.
packages/core/src/engine/engine.ts
packages/core/src/engine/checkpoint.ts
packages/shared/src/run-event.ts
packages/shared/src/constants.ts
packages/core/src/engine/checkpoint.test.ts
packages/shared/src/run-event.test.ts
Extend retry configuration schema and node types to support above-chain retry on non-agent nodes with validated retry_on codes.
  • Extend RetrySchema to include optional backoff_ms and retry_on, where retry_on is a non-empty array of RETRYABLE_ERROR_CODES; introduce RETRYABLE_ERROR_CODES and RetryableErrorCode in shared constants.
  • Allow condition, transform, and merge node schemas to carry an optional retry field referencing RetrySchema, making their sandboxed execution eligible for engine-level retry.
  • Add tests verifying NodeSchema accepts retry on condition/transform/merge and rejects retry_on entries that are non-retryable or empty.
  • Clarify RetrySchema and retry semantics in comments as the engine-level above-chain node retry budget applied across node types.
packages/shared/src/agent.ts
packages/shared/src/node.ts
packages/shared/src/node.test.ts
packages/shared/src/constants.ts
Update AgentRunner wiring so node.retry is no longer used for within-chain primary retries and add coverage for agent-level retry via engine budget.
  • Change buildPlanEntries so the primary FallbackPlanEntry always uses maxAttempts: 1 and no longer derives attempts/backoff from node.retry or agent.retry, aligning with ADR-0040.
  • Update AgentRunner tests to assert that node.retry does not affect within-chain primary retry, and that retryable errors surface back to the engine for above-chain handling.
  • Add an e2e test showing an agent node without node.retry but with agent.retry is retried by the engine via the resolved retry config, validating precedence node.retry ?? agent.retry for agents.
packages/core/src/engine/agent-runner.ts
packages/core/src/engine/agent-runner.test.ts
packages/core/src/engine/agent-runner.e2e.test.ts
Add engine tests for node-level retry behavior, including backoff, cancellation, sibling failures, and no-budget behavior.
  • Introduce a new engine test suite that defines workflows with transform nodes carrying retry budgets and stub handlers that simulate flaky, fatal, and retry-on-filtered failures.
  • Test recovery within budget, budget exhaustion, fatal failures skipping retry, retry_on exclusion, cancellation during backoff, sibling fatal failures aborting pending retries, and behavior when no retry budget is configured.
  • Test exponential backoff defaults, explicit backoff base, and the 24h clamp to ensure delayMs stays within safe bounds and avoids overflow.
packages/core/src/engine/engine.test.ts
Document ADR-0040 and update specs and roadmap to reflect above-chain node-retry semantics and deferred retry-from-node feature.
  • Add ADR-0040 describing node-level retry above the provider fallback chain, its interaction with the FallbackChain, retry_on semantics, checkpoint behavior, and deferral of retry-from-node (Part B).
  • Update ADR-0038 with an "Amended by ADR-0040" note clarifying that node/agent retry no longer feeds into the primary FallbackPlanEntry.
  • Update agent YAML spec, workflow YAML spec, SSE event schema docs, and decisions index to describe retry as above-chain node-level retry applicable to agent/condition/transform/merge, and to document the new node:retrying event and attemptNumber fields.
  • Add a deferred-tasks entry explaining why retry-from-node is postponed to Phase-2 with a persistent store and run-attempt model.
docs/decisions/0040-node-retry-budget-above-the-chain.md
docs/decisions/0038-agentrunner-llm-call-boundary.md
docs/decisions/README.md
docs/reference/contracts/agent-yaml-spec.md
docs/reference/contracts/workflow-yaml-spec.md
docs/reference/contracts/sse-event-schema.md
docs/roadmap/deferred-tasks.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements ADR-0040, introducing an engine-level node-retry budget above the provider fallback chain. It allows re-dispatching whole nodes (including agent, condition, transform, and merge nodes) on retryable failures with linear or exponential backoff. Key changes include adding a new non-terminal node:retrying event, updating schemas to support extended retry configurations (backoff_ms, retry_on), and implementing the retry dispatch loop in WorkflowEngine. The review feedback identifies two important improvements: tracking the initial start time of the first attempt in #dispatch to ensure durationMs accurately reflects the total execution slot time across retries, and adding the 'tool' node type to the #retryConfig switch case to enable retries for tool nodes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +601 to +646
async #dispatch(vertex: PlanVertex, firstAttempt: number): Promise<void> {
const retry = this.#retryConfig(vertex);
let attempt = firstAttempt;
for (;;) {
const startedAtMs = this.#elapsedMs();
const outcome = await this.#runAttempt(vertex, attempt);
const willRetry =
outcome.kind === 'failed' &&
!this.#settled &&
!this.#cancelling &&
this.#shouldRetry(retry, outcome.error, attempt);
if (!willRetry || outcome.kind !== 'failed') {
await this.#onOutcome(vertex, outcome, startedAtMs, attempt);
return;
}
const delayMs = this.#backoffMs(retry, attempt);
await this.#emitDurable({
type: 'node:retrying',
runId: this.runId,
nodeId: vertex.id,
attemptNumber: attempt,
error: {
code: outcome.error.code,
message: outcome.error.message,
retryable: outcome.error.retryable,
},
delayMs,
});
const sleptFully = await this.#abortableSleep(delayMs);
if (this.#settled) {
return; // the run settled (e.g. a sibling failure/cancel) while we waited — drop this re-dispatch
}
if (this.#cancelling || this.#abort.signal.aborted) {
// A cancel / sibling-abort landed on the same tick the timer fully elapsed (so sleptFully was true
// but the run is now ending) — settle this node's last failure rather than waste a re-dispatch.
await this.#onOutcome(vertex, outcome, startedAtMs, attempt);
return;
}
if (!sleptFully) {
// The run's AbortSignal fired during the backoff — a cancel OR a sibling node's failure (which
// aborts to stop other branches). Do not re-dispatch; settle this node's last failure. #settleFailed
// honours precedence: it won't overwrite an already-set #failure (a sibling's root cause) nor set one
// while cancelling — so the run closes as the sibling's run:failed, or run:cancelled, accordingly.
await this.#onOutcome(vertex, outcome, startedAtMs, attempt);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The durationMs reported in the node:completed event currently only measures the duration of the final successful attempt, because startedAtMs is reset at the beginning of each attempt. Since the node is considered running for the entire duration of the retry loop (including previous failed attempts and backoff sleep), this creates an inconsistency between the timestamp of the first node:started event and the reported durationMs. Tracking the initial start time of the first attempt ensures durationMs accurately reflects the total time the node held the execution slot.

  async #dispatch(vertex: PlanVertex, firstAttempt: number): Promise<void> {
    const retry = this.#retryConfig(vertex);
    let attempt = firstAttempt;
    const initialStartedAtMs = this.#elapsedMs();
    for (;;) {
      const startedAtMs = this.#elapsedMs();
      const outcome = await this.#runAttempt(vertex, attempt);
      const willRetry =
        outcome.kind === 'failed' &&
        !this.#settled &&
        !this.#cancelling &&
        this.#shouldRetry(retry, outcome.error, attempt);
      if (!willRetry || outcome.kind !== 'failed') {
        await this.#onOutcome(vertex, outcome, initialStartedAtMs, attempt);
        return;
      }
      const delayMs = this.#backoffMs(retry, attempt);
      await this.#emitDurable({
        type: 'node:retrying',
        runId: this.runId,
        nodeId: vertex.id,
        attemptNumber: attempt,
        error: {
          code: outcome.error.code,
          message: outcome.error.message,
          retryable: outcome.error.retryable,
        },
        delayMs,
      });
      const sleptFully = await this.#abortableSleep(delayMs);
      if (this.#settled) {
        return; // the run settled (e.g. a sibling failure/cancel) while we waited — drop this re-dispatch
      }
      if (this.#cancelling || this.#abort.signal.aborted) {
        // A cancel / sibling-abort landed on the same tick the timer fully elapsed (so sleptFully was true
        // but the run is now ending) — settle this node's last failure rather than waste a re-dispatch.
        await this.#onOutcome(vertex, outcome, initialStartedAtMs, attempt);
        return;
      }
      if (!sleptFully) {
        // The run's AbortSignal fired during the backoff — a cancel OR a sibling node's failure (which
        // aborts to stop other branches). Do not re-dispatch; settle this node's last failure. #settleFailed
        // honours precedence: it won't overwrite an already-set #failure (a sibling's root cause) nor set one
        // while cancelling — so the run closes as the sibling's run:failed, or run:cancelled, accordingly.
        await this.#onOutcome(vertex, outcome, initialStartedAtMs, attempt);
        return;
      }
      attempt += 1;
      await this.#emitDurable({
        type: 'node:started',
        runId: this.runId,
        nodeId: vertex.id,
        nodeType: vertex.type,
        attemptNumber: attempt,
      });
    }
  }

Comment on lines +691 to +703
#retryConfig(vertex: PlanVertex): Retry | undefined {
const config = vertex.config;
switch (config.kind) {
case 'agent':
return config.node.retry ?? config.resolvedAgent?.retry;
case 'condition':
case 'transform':
case 'fan_in':
return config.node.retry;
default:
return undefined;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The tool node type is defined as an engine node type in ENGINE_NODE_TYPES and can produce transient failures (such as tool_failed). However, it is currently omitted from the #retryConfig switch case, which prevents tool nodes from being retried even if they have a retry configuration. Adding 'tool' to the switch case ensures it is handled consistently with other retryable non-agent nodes.

Suggested change
#retryConfig(vertex: PlanVertex): Retry | undefined {
const config = vertex.config;
switch (config.kind) {
case 'agent':
return config.node.retry ?? config.resolvedAgent?.retry;
case 'condition':
case 'transform':
case 'fan_in':
return config.node.retry;
default:
return undefined;
}
}
#retryConfig(vertex: PlanVertex): Retry | undefined {
const config = vertex.config;
switch (config.kind) {
case 'agent':
return config.node.retry ?? config.resolvedAgent?.retry;
case 'condition':
case 'transform':
case 'fan_in':
case 'tool':
return config.node.retry;
default:
return undefined;
}
}

cemililik and others added 2 commits June 15, 2026 14:58
- durationMs (gemini, medium): a retried node held its slot from the first
  node:started, but #dispatch reset startedAtMs each attempt, so the terminal
  node:completed/node:failed durationMs measured only the final attempt. Hoist the
  start to the first attempt → durationMs now reflects the whole node (attempts +
  backoffs), consistent with the first node:started.
- #shouldRetry (Sonar minor): use .includes over .some for the retry_on membership
  check, widening retry_on to readonly string[] (a safe widening, no cast) so it
  accepts the wider ErrorCode.
- engine.test.ts (Sonar critical + 2×major): hoist fireBackoff + flaky to module
  scope; rewrite fireBackoff's bound as a while-loop with a separate guard counter
  (the for-loop's condition tested host.armedCount() while the incrementer updated i).

Skipped: gemini's "add `tool` to #retryConfig" — there is no `tool` PlanConfig kind
(the kinds are fan_out/fan_in/agent/condition/transform/human_in_the_loop/input/output);
`tool` is a reserved engine type, not authorable in v1.0, and tool failures occur inside
agent nodes (already covered by the agent arm). `case 'tool'` would not typecheck.

CI was green pre-fix (SonarCloud quality gate passed; CodeRabbit was rate-limited).

Refs: ADR-0040
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…purious node:retrying, retry hardening)

Final unconstrained multi-agent (Opus) review of the 1.S diff surfaced 7 distinct
verified issues (no blockers/highs). Fixes, by area:

Behaviour (engine.ts):
- Spurious node:retrying: a sibling node's FATAL failure sets #failure + aborts the
  signal WITHOUT setting #cancelling, so a budgeted node whose own retryable failure
  resolved a tick later would emit a non-terminal node:retrying it never honours (the
  backoff then short-circuits straight to node:failed). Add `#failure === undefined &&
  !#abort.signal.aborted` to the willRetry guard so an already-doomed node settles its
  last failure cleanly. Regression test added (sibling-failure ordering).
- #shouldRetry defence-in-depth: the retry_on-absent path now also gates on
  RETRYABLE_ERROR_CODES, so the engine never re-dispatches a non-transient code even if
  a future executor mis-sets `retryable: true` (e.g. on an `internal` failure). Inert
  today; folds both branches into one membership check.

Contract accuracy — the two attemptNumber families (run-event.ts, sse-event-schema.md,
ADR-0040 A.5/A.7 + Consequences). node:*.attemptNumber is the engine's NODE-RETRY
dispatch index; cost:updated / agent:* carry the WITHIN-CHAIN (FallbackChain) index,
which resets per re-dispatch — so node:completed.attemptNumber=2 can ride alongside
cost:updated.attemptNumber=1. The docs claimed node:completed "matches cost:updated";
they no longer do. Corrected the comments, added a "Two attemptNumber families" spec
note, and fixed the ADR's "consistent with cost:updated" / "per attempt across both
layers" wording (cost is tallied per attempt and folded run-wide; per-node-retry-attempt
bucketing is by stream order at node:started/node:retrying boundaries, not a join).
Added a regression test pinning that attempt-1 omits attemptNumber (the replay contract).

Docs/maintainability:
- node:retrying error shape now derives from the canonical eventErrorFields
  (.omit({ correlationId: true })) instead of a hand-copy, so it can't drift.
- node:failed wording: acknowledge it is also emitted when a pending retry is abandoned
  by a cancel/sibling-abort (budget unspent), not only on exhaustion/fatal.
- ADR-0040 A.3: document the concurrency-slot trade-off (a node holds its max_parallel
  slot across the backoff sleep) and that retry.max is intentionally unbounded (schema
  convention; the 24h backoff cap, not a max ceiling, is the guardrail).

647 tests green (+2); format/lint/typecheck/build clean; Leakwatch 0.

Refs: ADR-0040
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/core/src/engine/agent-runner.e2e.test.ts (1)

364-367: ⚡ Quick win

Consider adding a timeout guard to the backoff-wait loop.

The inline while (host.armedCount() === 0) loop mirrors fireBackoff from engine.test.ts but lacks its 1000-iteration timeout. If the timer is never armed (e.g., due to a regression), this loop would hang indefinitely. The engine.test.ts helper throws after 1000 iterations to fail fast.

🛡️ Defensive timeout pattern
       if (event.type === 'node:retrying') {
-        while (host.armedCount() === 0) await Promise.resolve();
+        let waited = 0;
+        while (host.armedCount() === 0) {
+          waited += 1;
+          if (waited > 1000) {
+            throw new Error('backoff timer was never armed after node:retrying');
+          }
+          await Promise.resolve();
+        }
         host.fireTimers();
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/engine/agent-runner.e2e.test.ts` around lines 364 - 367,
The while loop checking host.armedCount() === 0 in the 'node:retrying' event
handler lacks a timeout guard and can hang indefinitely if the timer is never
armed, unlike the fireBackoff helper pattern used in engine.test.ts. Add a
counter to the loop that increments with each iteration and throw an error after
1000 iterations to fail fast if the armed count never reaches a non-zero value,
ensuring the test does not hang due to regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/core/src/engine/agent-runner.e2e.test.ts`:
- Around line 364-367: The while loop checking host.armedCount() === 0 in the
'node:retrying' event handler lacks a timeout guard and can hang indefinitely if
the timer is never armed, unlike the fireBackoff helper pattern used in
engine.test.ts. Add a counter to the loop that increments with each iteration
and throw an error after 1000 iterations to fail fast if the armed count never
reaches a non-zero value, ensuring the test does not hang due to regressions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dd6bd6ba-ee96-4715-ab1e-347a35886a87

📥 Commits

Reviewing files that changed from the base of the PR and between 48a9f27 and e579774.

📒 Files selected for processing (20)
  • docs/decisions/0038-agentrunner-llm-call-boundary.md
  • docs/decisions/0040-node-retry-budget-above-the-chain.md
  • docs/decisions/README.md
  • docs/reference/contracts/agent-yaml-spec.md
  • docs/reference/contracts/sse-event-schema.md
  • docs/reference/contracts/workflow-yaml-spec.md
  • docs/roadmap/deferred-tasks.md
  • packages/core/src/engine/agent-runner.e2e.test.ts
  • packages/core/src/engine/agent-runner.test.ts
  • packages/core/src/engine/agent-runner.ts
  • packages/core/src/engine/checkpoint.test.ts
  • packages/core/src/engine/checkpoint.ts
  • packages/core/src/engine/engine.test.ts
  • packages/core/src/engine/engine.ts
  • packages/shared/src/agent.ts
  • packages/shared/src/constants.ts
  • packages/shared/src/node.test.ts
  • packages/shared/src/node.ts
  • packages/shared/src/run-event.test.ts
  • packages/shared/src/run-event.ts

The 'node:retrying' handler in the agent-node retry e2e busy-waited for the backoff
timer to arm with an unbounded `while (host.armedCount() === 0)` loop — if a regression
ever stopped arming the timer the test would hang instead of failing. Add the same
bounded fail-fast guard the fireBackoff helper uses in engine.test.ts (throw after 1000
idle iterations). Test-only; no behaviour change.

647 tests green; format/lint/typecheck clean.

Refs: ADR-0040
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@cemililik
cemililik merged commit 4bcb86c into main Jun 15, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant