fix(tools): tell a failed async media generation from a slow one [SAP-3097] - #771
Conversation
Review — PR #771 (round 1)🔒 CONFIDENTIALITY — internal gateway symbol ships in the npm tarball
Rewrite to the generic role — "mirrors the gateway's own terminal-status classification Changeset + README + type assert behavior nothing in this repo produces
Two related pieces of the same root cause:
Fix: scope the payload claims to "populated by the platform once the gateway change is
|
|
Four findings, three fixed in 6101551, one I'm pushing back on. ✅
|
Review — PR #771 (round 2, delta since
|
|
Both round-2 findings are real. Fixed in 070bcbc. ✅ Empty
|
A caller who launched an async image or video generation could not tell a failed job from a slow one, on either of the two paths that report one. `wait()` treated EVERY non-OK poll response as "still generating", so a job that terminally failed in three seconds burned the caller's whole `timeoutMs` and then threw `Image generation did not complete within 300000ms` — which tells the caller the opposite of what happened. It now reads the queue's terminal state and throws the new `ContentGenerationFailedError` as soon as the job fails, carrying `requestId` and the provider's own `providerError`. A plain timeout `Error` now means only what it says: still running when we stopped waiting. The poll loop is shared between image and video (`poll.ts`) so their terminal semantics cannot drift apart, and `video.create` — which polls the same way — gets the same behavior. A transport blip must still keep polling, so a non-OK result poll (ambiguous on its own: failed, not-done-yet, and blipped all look alike) is disambiguated against the status endpoint, the canonical terminal channel. Anything short of an explicit terminal marker keeps the poll going. `terminalFailureFrom` mirrors the gateway's own `terminalStatusFromQueueResponse` so the two agree. `ImageResultPayload` / `VideoResultPayload` outputs gain `generationError`. The provider error already reached the resume payload, but on `storageError` — the field documented as "persisting this output failed" — so a resumed step concluded storage broke when nothing had been generated at all. The two are now separate fields, never both set on one output, so a step branches without string-matching a message. This is also what makes `VIDEO_RESULT_SIGNAL`'s documented "carries the result either way (ready OR failed)" claim true; the payload previously had nowhere to put the failure. Depends on sapiom/Sapiom#4801, which emits `generationError` on the wire. SAP-3097 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015XHWBRT86s84urfsTY9JJe
Review follow-ups on the SAP-3097 poll loop.
`{ status: "COMPLETED", error: "…", images: [] }` resolved `wait()` with an
empty result instead of throwing: the "an asset beside a failure marker wins"
shortcut ran the result predicate first, and the image predicate accepts any
array. An empty container is not an asset — it is the ambiguity this change
exists to remove — so the terminal check now runs first. A non-terminal
`images: []` still resolves, unchanged from before SAP-3097.
The status probe ran on every non-OK poll. Some queues report "not ready yet"
that way, so a 5-minute video at the 5s default went from ~60 gateway requests
to ~120, per waiting caller. It is a tie-break, not a second poll: probe on the
first non-OK and every 4th after, which still catches a job that failed
immediately, bounds detection to four poll intervals, and holds the extra load
to ~25%.
Drop a private gateway symbol name and an internal architecture detail from
`poll.ts`'s JSDoc — `declaration: true` emits it into the published .d.ts.
Scope the published copy to what this release actually does. The `wait()` half
is live; `generationError` is a type the platform populates once the gateway
change deploys, and a step running against an older gateway still sees the
failure on `storageError`. The changeset, README, and field docs now say so and
point at the `generationError` ?? `storageError` fallback that reads correctly
on both sides of that deploy.
SAP-3097
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015XHWBRT86s84urfsTY9JJe
Round-2 review follow-ups. `hasQueueError` tested key presence (`!= null`), so `error: ""` counted as a failure. Harmless while the success path ran first, but the previous commit moved the terminal check ahead of the result predicate — so a queue that emits `error: ""` on a clean completion would now throw `ContentGenerationFailedError` while holding the asset in hand. COMPLETED is terminal-SUCCESS by default and the error content is the only thing that makes it a failure, so that content has to be real: the branch now gates on `providerErrorMessage`, which already required a non-empty trimmed value. The statuses that are terminal on their own (FAILED / ERROR / CANCELLED) are unaffected and keep their "job reported <status>" fallback. The probe-cadence test asserted `polls > 4` inside a 30ms budget against real timers, which a loaded CI box can miss for no reason. It now scripts 8 non-OK polls followed by the result and asserts exact counts — 9 polls, 2 probes. SAP-3097 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015XHWBRT86s84urfsTY9JJe
Rebase follow-up. SAP-3098 (#770) landed a full `images.launch` section on main while this branch was open, and three parts of it describe the behaviour this PR changes: - It documents the bug as current: "a terminal provider failure is not currently distinguishable from a slow one … surfaces as the deadline Error". That is the before-state; `wait()` now throws `ContentGenerationFailedError` on the failure. - Its new `ImageResultPayload` block predates `generationError`, so the field was documented on the video payload only. - Its `IMAGE_RESULT_SIGNAL` section says the signal fires "when the job reaches a terminal state" without saying the payload can now carry the failure — the same either-outcome note `VIDEO_RESULT_SIGNAL` gets. The conflict itself was one paragraph: #770 rewrote the `VIDEO_RESULT_SIGNAL` lead-in to describe the `pause` edge, this branch added the either-outcome sentence. Both kept. SAP-3097 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015XHWBRT86s84urfsTY9JJe
070bcbc to
21cf178
Compare
Problem
A caller who launched an async image or video generation could not tell a failed job from a slow one, on either of the two paths that report one.
1.
wait()polled to the deadline on a terminal failure. Every non-OK poll response was treated as "still generating", so a job that terminally failed in three seconds burned the caller's wholetimeoutMsand then threwImage generation did not complete within 300000ms— which tells the caller the opposite of what happened.2. The resume payload had no generation-failure channel. The provider error already reached a resumed workflow step, but on
storageError— the field documented as "Present when storage was requested but persisting this output failed." A step branching on that type concluded persistence broke, not that the asset was never generated.Change
wait()fails fast, with the provider's messageNew
ContentGenerationFailedError(exported from@sapiom/tools), carryingrequestIdand the provider's ownproviderError. A plain timeoutErrornow means only what it says: still running when we stopped waiting.The poll loop moves to
content-generation/poll.tsand is shared byimages.launch().wait(),video.launch().wait(), andvideo.create(), so their terminal semantics cannot drift apart.A transport blip still keeps polling
Two channels report terminal failure, and both are read:
terminalFailureFromclassifiesstatuspluserror/error_type, mirroring the gateway's own classification of the same wire contract so the two agree. It is deliberately conservative — only an explicit terminal marker ends the poll, so an unfamiliar body never gets reported as a generation failure.STATUS_PROBE_EVERY), which still catches an immediate failure, bounds detection to four poll intervals, and holds the extra load to ~25%.The failure marker is read before the result predicate. A terminal body can still carry an empty container for the asset it never produced —
{ status: "COMPLETED", error: "…", images: [] }— and reporting that as a successful empty result is the exact ambiguity this change removes. Conversely, only real error content makes aCOMPLETEDjob terminal: a present-but-emptyerror: ""does not fail a job that is handing back its asset.generationErroron the resume payloadImageResultPayload/VideoResultPayloadoutputs gaingenerationError?: string. The platform sends it instead of the storage fields, never alongside, so a resumed step branches without string-matching a message:Nothing in this package produces
generationError— it appears on the wire once the gateway change that emits it is deployed, and a step running against an older gateway still sees a generation failure onstorageError. CheckinggenerationErrorfirst and falling back, as above, reads correctly on both sides of that deploy. The changeset, README, and field JSDoc all say so, so the published CHANGELOG doesn't claim a fix that hasn't shipped.This is also what makes
VIDEO_RESULT_SIGNAL's documented "carries the result either way (ready OR failed)" claim expressible — the payload previously had nowhere to put the failure.IMAGE_RESULT_SIGNALnow documents the same contract.Three templates that read the resume payload (
scene-to-video,content-repurposing-pipeline,research-to-microsite) are updated to report the right failure.Depends on
sapiom/Sapiom#4801 — the gateway half, which emits
generationErroron the wire. This PR's type is inert until that ships; nothing regresses in the meantime.Testing
npx jest --maxWorkers=1 src/content-generation/index.spec.ts— 143 pass (31 new). Coverage per the ticket's acceptance criteria:ERROR,COMPLETED-with-error,CANCELLED), asserted to give up on the first poll rather than run totimeoutMsimages: []throws; a non-terminal one still resolvesCOMPLETEDjob carryingerror: ""alongside its asset resolvesError, notContentGenerationFailedErrorterminalFailureFromfor every keep-polling and every terminal shapepnpm typecheckandpnpm lintclean inpackages/tools.Closes SAP-3097
🤖 Generated with Claude Code
https://claude.ai/code/session_015XHWBRT86s84urfsTY9JJe