Skip to content

bug(service-automation): a retry attempt that PAUSES is recorded as failed and its suspension is never persisted — executeWithoutRetry has no isSuspendSignal arm #9510

Description

@os-project-manager

Found while implementing #9414 (terminal messages on the execute() exits, PR to follow).
Filed rather than fixed there: different defect class — a lost durable pause, not a missing
result field — and repairing it changes what a retrying flow does, which #9414's ruling
does not cover.

Measured

packages/services/service-automation/src/engine.ts, origin/main @ e3a86e390.

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, calls persistSuspendedRun, records a
paused log entry and returns { success: true, status: 'paused', runId }.

executeWithoutRetry() — the method retryExecution re-runs the flow through on every
retry attempt — has no such arm. Its catch is:

}catch(err: unknown){consterrorMessage=errinstanceofError ? err.message : String(err);// …recordLog({ status: 'failed', … })return{success: false,error: errorMessage, durationMs,status: 'failed',summary: logged.summary};}

A SuspendSignal thrown on a retry attempt therefore falls into the generic failure path:

  • no persistSuspendedRun, so the continuation is never stored — the run cannot be
    resumed by anyone, ever;
  • the run log records status: 'failed' for a run that actually asked to pause;
  • the caller gets status: 'failed' with the suspend signal's own message as error;
  • retryExecution reads only result.success, so it treats the pause as one more failed
    attempt and keeps burning retries.

Reachability

Not the first attempt — execute() handles that one correctly, and a flow only reaches
retryExecution after a failure. It is a later attempt that is exposed: a flow with
errorHandling.strategy: 'retry' whose first attempt fails downstream of, or before, a
pausing node, and whose retry then reaches that node. A flaky HTTP/connector call followed
by an approval or screen node is the ordinary shape of this — retry is exactly what an
author reaches for on the flaky half.

Not decided here

Whether the repair is to lift execute()'s suspend arm into executeWithoutRetry (a
paused retry attempt becomes a normal durable pause and the retry loop stops), or to refuse
the combination at authoring time. The first is the obvious reading — a pause is not a
failure, and that is stated in execute()'s own comment — but it makes retryExecution
able to return a non-terminal result, which its one caller and the trigger route both read
as terminal today, so it wants a ruling rather than a guess.

Refs


Generated by Claude Code

Metadata

Metadata

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions