feat(flow): postpone-and-auto-resume steps on transient provider errors - #20
Conversation
A step whose agent run fails with a transient LLM-provider/gateway error —
rate limit (HTTP 429), overloaded/5xx upstream, or an HTTP/2 stream reset the
provider already exhausted its in-call retries on — currently fails the whole
flow (routing to fallback.to). These are self-healing capacity blips: three
consecutive MICRO-1014 runs died this way, e.g.
failed to process events: maximum retry attempts reached for HTTP 429: 8 retries
failed to process events: stream error: stream ID 147; INTERNAL_ERROR; received from peer
Park such a step as `postponed` (not `failed`) when it opts in via `resume_after`,
so the orchestrator's existing postpone sweep re-enters it after the delay, when
the endpoint has recovered. Its resume-chain loop-breaker already bounds a
persistently-degraded upstream, and steps without `resume_after` keep their
current terminal-failure behaviour.
- flow.go: add Step.ResumeAfter (parsed here now; the orchestrator already reads
the same `resume_after` key to schedule the timed resume — no orchestrator
change needed, both parsers are lenient on the other's keys).
- service.go: isTransientProviderError classifier + postponeStepForTransientError;
guard both step-failure paths (the retry-loop exhaustion path and handleStepError).
- No AgentEvent error is emitted and no fallback is routed — this is a pause, not
a failure, so the run ends terminal-postponed and auto-resumes.
Tests: internal/flow/transient_postpone_test.go covers the classifier (incl. the
two observed error strings, wrapped errors, and non-transient negatives) and the
resume_after opt-in gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the `resume_after` step field to the flow-spec reference: timed auto-resume of a postponed step, and the opt-in it grants for postpone-and-auto-resume on a transient provider error (rate limit / overloaded / HTTP-2 stream reset). Documents the fallback-vs-postpone distinction and the must-run-now caveat. Doc counterpart to the runtime behaviour added in this branch (Step.ResumeAfter + isTransientProviderError/postponeStepForTransientError in internal/flow). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
obukhovaa
left a comment
There was a problem hiding this comment.
Review — postpone-and-auto-resume on transient provider errors
Solid, well-scoped change. I checked out the branch and independently ran go test ./internal/flow/... — green. The core mechanics hold up:
- No dangling goroutine / hang. The postpone paths just
return, anddefer wg.Done()(service.go:361) covers everyrunStepreturn, so the flow still converges. - Consistent with existing postpone. The normal postpone path (service.go:520-522) already returns without emitting an
agentEvent, so "pause, not a failure — noAgentEventerror, nofallbackroute" matches established behaviour rather than inventing a new one. - Resume re-runs the step.
collectResumableStepsre-enters apostponedrow aspostpone:false→status=running(service.go:1061-1067), so the parked step actually re-executes at its carried iteration. Output is nulled on park, which is correct here since a transient failure produced none. - Classifier is conservative on the right axis.
context.Canceled/context.DeadlineExceeded(graceful shutdown, step-timeout) don't match any signature, so a cancelled ctx or a step that blew its ownTimeoutstill fails terminally instead of parking — good.
A few notes, none blocking:
1. The only real bound on a park→resume→park cycle is external (please confirm)
Within opencode nothing caps repeated transient-postpones: postpone doesn't bump maxIterations, and each resume is a fresh Run with a fresh flow timeout. A persistently-throttled endpoint would park and auto-resume indefinitely. The PR delegates this to "the orchestrator's resume-chain loop-breaker" — worth confirming that breaker actually keys on this resume chain, since it's the sole thing standing between a bad-day upstream and an unbounded resume loop.
2. resume_after is gated on presence only, not parsed as a duration (opencode side)
stepPostponesOnProviderError accepts any non-blank string. opencode never parses the duration (the orchestrator owns that), so a typo like resume_after: "15minutes" still opts the step into parking, but may hand the orchestrator a value it can't schedule a wake from — parking with a broken wake timer. Since the two repos are intentionally decoupled by lenient yaml.Unmarshal, consider validating it parses as a Go duration at flow-load so a malformed value fails fast on opencode's side too. Optional.
3. Broad substring classifier — small but nonzero false-positive surface
"rate limit", "service unavailable", "overloaded", "too many requests" match anywhere in the surfaced message. Blast radius is small today because a step-level error originates from the agent runtime (result.Error), the empty-output guard, or a ctx error — not from tool output. Just flagging that any future error surface carrying those words on a resume_after step would park rather than fail.
4. Nit: guard sits above the row-exists precondition in handleStepError
handleStepError is also called from the pre-persist error paths (NewAgent / resolveSession / resolveInteractionTarget, service.go:418/431/437) before the entry-time flow_states row is written. The new guard there would UpdateFlowState a possibly-missing row. Not reachable today (those errors carry no provider signatures) and it degrades gracefully (warn + emit postponed), so purely a note in case those error surfaces ever change shape.
I pushed one commit to this branch: docs(flow): document resume_after in flow-creator skill — ports the flow-spec.md reference change (the field entry + the resume_after section) so the runtime behaviour is documented alongside the code.
…iew) Address review notes on #20: - (note 2) Add Step.ResumeAfterDuration() and validate it in validateFlow, so a malformed resume_after (e.g. "15minutes") fails fast at flow-load instead of silently degrading to the orchestrator's default wake — mirrors the existing TimeoutDuration() load-time check. Blank/unset stays valid (bare opt-in). - (note 4) postponeStepForTransientError now Get-or-Creates the flow_states row instead of a blind UpdateFlowState. handleStepError can fire from the pre-persist setup paths where the entry-time `running` row doesn't exist yet; the blind UPDATE would silently no-op there. Not reachable today (those errors carry no provider signatures) but makes the helper correct regardless of call site. Tests: add TestStepResumeAfterDuration (valid/blank/typo/zero/negative). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eview) Review note 1: the PR claimed the orchestrator's resume-chain loop-breaker bounds a persistently-throttled endpoint. It does not — breakResumeLoop returns early unless the job's match_key has the TeamCity build-await prefix (postpone_sweep.go), so a work-item job that keeps transient-postponing was unbounded. Add an opencode-side backstop: postponeStepForTransientError now declines to park (returns false → caller fails the step terminally) once the step's flow_states row is older than maxTransientPostponeAge (2h). created_at is set on insert and preserved across the UPDATE-on-resume, so it measures total park→resume wall-clock without needing a per-resume counter (args are replaced by caller args on resume; iteration is semantically overloaded — neither can carry one). Both call sites now gate the early return on the returned bool. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the thorough review — and for porting the flow-spec doc. Addressed in 1. Unbounded park→resume chain — you were right, it was not boundedI traced Fixed in opencode rather than depending on an orchestrator change: 2. Validate
|
Problem
A flow step whose agent run fails with a transient LLM-provider/gateway error currently fails the whole flow (routes to
fallback.to). But these are self-healing capacity blips, not real failures. Three consecutivedeveloper-react-on-jiraruns for one Jira issue died this way, each on the first model call of a step (no output produced):The provider layer already retries these (8× on 429, 3× on RST) — but when the upstream (EU-regional Bedrock via the gateway) is saturated for the whole window, the budget exhausts and the error becomes fatal. There's no way to say "the endpoint is busy, come back later."
Fix
When a step opts in via
resume_after, a transient provider error parks it aspostponedinstead offailed, so the orchestrator's existing postpone sweep re-enters it after the delay — when the endpoint has recovered. No orchestrator change is needed: it already reads the sameresume_afterkey to schedule the timed resume, and its resume-chain loop-breaker already bounds a persistently-degraded upstream. Steps withoutresume_afterkeep their current terminal-failure behaviour.flow.go— addStep.ResumeAfter. It's parsed here now so the runner can gate on it; the orchestrator already parses the same YAML key. Both sides use lenientyaml.Unmarshal, so each tolerates the field the other owns — no cross-repo lockstep.service.go—isTransientProviderError(string-match on the surfaced message, since the typed provider error is wrapped before it reaches the flow layer; mirrorsprovider.isRetryableRSTStreamError/isTransientStreamErrorshapes) +postponeStepForTransientError. Both step-failure paths are guarded: the retry-loop exhaustion path andhandleStepError.AgentEventerror is emitted and nofallbackis routed, so the run ends terminal-postponedand auto-resumes.Opt-in gate is
resume_after(single key, no footgun): a step can't ask to postpone but forget to set the wake timer. Deliberately not applied to must-run-now steps (e.g. a salvage step that pushes local WIP before the workspace is torn down) — a resume runs in a fresh workspace where that state is gone.Tests
internal/flow/transient_postpone_test.go: classifier table (both observed error strings, wrapped errors,overloaded/ThrottlingException/ServiceUnavailableException, and negatives incl. a client-sent stream error that must NOT match) + theresume_afteropt-in gate.gofmt,go build ./...,go vet, and the fullgo test ./internal/flow/...pass locally on go 1.25.8.The consumer flow change (adding
resume_afterto thedeveloper-react-on-jirawork steps) is a separate MR inpiano/ai-agents/piano-developer.🤖 Generated with Claude Code