An image-to-video chain has two remote jobs. Nano Banana returns a task identifier before the image exists. Wan 3.0 must not receive its first-frame request until that task is complete and its output address is saved.
The race appears when automation treats “image POST succeeded” as “image exists.” The video step then receives an empty address or a submission object. A fixed delay makes the bug less frequent; it does not guarantee order.
Use a durable state machine: one owner per transition, bounded polling, and restart from saved task identifiers.
The Invariant That Prevents the Race
Write this rule at the top of the workflow:
A Wan 3.0 submission is legal only when the image state is image_complete and a non-empty image_url has been committed to storage.
A successful POST or an image_task_id is insufficient. The ID makes the job pollable; it does not populate output.image_urls.
Likewise, a Wan submission returns an ID; a later completed task carries output.video_urls.
Use States That Describe Evidence
Avoid one vague status such as running. The following states say exactly what is known:
| State | Durable evidence | Permitted next action |
|---|---|---|
| image_ready | prompt and local pipeline key saved | claim the image submission lease |
| image_submitting | one worker owns the POST attempt | submit once; do not let another worker enter |
| image_submit_unknown | request may have arrived, but no task ID was saved | reconcile or review; never auto-submit |
| image_processing | image task ID saved | poll that ID |
| image_complete | image URL saved | prepare the Wan request |
| video_ready | image URL and motion prompt saved | claim the video submission lease |
| video_submitting | one worker owns the POST attempt | submit once |
| video_submit_unknown | Wan may have accepted the request | reconcile or review; never auto-submit |
| video_processing | video task ID saved | poll that ID |
| complete | video URL saved | publish or archive according to policy |
| failed | terminal error saved | show the error; retry only under an explicit rule |
If a POST arrives remotely but its response is lost, the client cannot know if a task exists. An automatic repeat may create a second job, which is why submit_unknown is distinct.
Freeze a Regression Fixture First
Approve one Nano Banana still, its prompt, one five-second Wan 3.0 motion prompt, the ratio and first-frame checksum. Assemble that fixture in ClipDance and freeze it while testing orchestration, so prompt revisions cannot be mistaken for state failures.
The fixture answers four operational questions: Was one image task created? Did video wait? Did restart reuse both IDs? Was one final video recorded?
Store the Workflow Before the First POST
Create the local row first. A unique pipeline_key makes duplicate events resolve to one workflow.
| Field | Purpose |
|---|---|
| pipeline_key | stable business key, unique |
| state | one of the states above |
| recipe_version | freezes prompts and generation settings |
| image_payload_hash | detects a changed image request |
| image_task_id | enables image polling after restart |
| image_url | gates the video submission |
| video_payload_hash | detects a changed video request |
| video_task_id | enables video polling after restart |
| video_url | final result |
| lease_until | stops concurrent submitters |
| last_error | preserves a terminal or ambiguous failure |
| updated_at | supports monitoring and stale-job alerts |
Derive the key from an order ID, asset ID and recipe version—not the current time. A new random key on retry defeats deduplication.
Submit Once, Then Poll by ID
Send both jobs through reAPI, save each POST’s returned id, and poll the task route until completed or failed.
This reference skeleton uses the documented model IDs and response fields at publication. Its store methods must be transactional. Run contract tests against current documentation before production use; this is not a claimed live test.
The image example uses a Nano Banana 2 route documented by reAPI at publication. Before running it, read the exact image and video IDs returned for the API key by /v1/models; keep those IDs in the recipe instead of substituting a marketing family name.
const API_BASE = process.env.REAPI_API_BASE;
const API_KEY = process.env.REAPI_API_KEY;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function request(path, options = {}) {
const response = await fetch(`${API_BASE}${path}`, {
…options,
headers: {
Authorization: `Bearer ${API_KEY}`,
‘Content-Type’: ‘application/json’,
…options.headers,
},
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(`Request rejected with ${response.status}`);
error.responseReceived = true;
error.status = response.status;
error.details = body;
throw error;
}
return body;
}
async function submit(path, payload) {
const task = await request(path, {
method: ‘POST’,
body: JSON.stringify(payload),
});
if (!task.id) throw new Error(‘Successful response did not contain id’);
return task.id;
}
async function pollBounded(taskId, maxWaitMs = 120_000) {
const deadline = Date.now() + maxWaitMs;
let delayMs = 2_500;
while (Date.now() < deadline) {
try {
const task = await request(`/api/v1/tasks/${taskId}`);
if (task.status === ‘completed’ || task.status === ‘failed’) return task;
} catch (error) {
const retryable =
!error.responseReceived || error.status === 429 || error.status >= 500;
if (!retryable) throw error;
}
await sleep(Math.min(delayMs, Math.max(0, deadline – Date.now())));
delayMs = Math.min(Math.round(delayMs * 1.5), 10_000);
}
return { id: taskId, status: ‘processing’ };
}
async function advance(jobId, store) {
let job = await store.get(jobId);
if (job.state === ‘image_ready’) {
job = await store.claim(jobId, ‘image_ready’, ‘image_submitting’);
if (!job) return;
try {
const id = await submit(‘/api/v1/images/generations’, {
model: ‘gemini-3.1-flash-image-preview’,
prompt: job.imagePrompt,
size: job.size,
resolution: job.imageResolution,
n: 1,
});
await store.saveTask(jobId, ‘image’, id, ‘image_processing’);
} catch (error) {
if (error.responseReceived && error.status < 500) {
await store.fail(jobId, error.details);
} else {
await store.markUnknown(jobId, ‘image’, String(error));
}
}
return;
}
if (job.state === ‘image_processing’) {
const task = await pollBounded(job.imageTaskId);
if (task.status === ‘processing’) return;
if (task.status === ‘failed’) return store.fail(jobId, task.error);
const imageUrl = task.output?.image_urls?.[0];
if (!imageUrl) return store.fail(jobId, ‘Completed image has no URL’);
await store.saveImage(jobId, imageUrl, ‘video_ready’);
return;
}
if (job.state === ‘video_ready’) {
job = await store.claim(jobId, ‘video_ready’, ‘video_submitting’);
if (!job) return;
try {
const id = await submit(‘/api/v1/videos/generations’, {
model: ‘wan3.0-video’,
prompt: job.motionPrompt,
image_with_roles: [{ url: job.imageUrl, role: ‘first_frame’ }],
size: job.size,
resolution: job.videoResolution,
duration: 5,
});
await store.saveTask(jobId, ‘video’, id, ‘video_processing’);
} catch (error) {
if (error.responseReceived && error.status < 500) {
await store.fail(jobId, error.details);
} else {
await store.markUnknown(jobId, ‘video’, String(error));
}
}
return;
}
if (job.state === ‘video_processing’) {
const task = await pollBounded(job.videoTaskId);
if (task.status === ‘processing’) return;
if (task.status === ‘failed’) return store.fail(jobId, task.error);
const videoUrl = task.output?.video_urls?.[0];
if (!videoUrl) return store.fail(jobId, ‘Completed video has no URL’);
await store.saveVideo(jobId, videoUrl, ‘complete’);
}
}
A response without an id is uncertain because remote acceptance cannot be proved. Classify it as submit_unknown, not as permission for another POST.
Bound Polling Without Turning a Timeout Into Failure
A polling deadline limits one worker invocation; it does not cancel the job. On processing, keep the state and schedule another worker with the same ID.
Retry temporary read errors with capped backoff and jitter. Alert on authentication errors or a confirmed missing task. Never send a polling timeout to image_ready or video_ready, because both permit a POST.
If a worker dies in image_submitting, returning it to image_ready may duplicate an accepted request. Move it to image_submit_unknown. Without documented remote idempotency or client-key lookup, a lost response prevents an exactly-once guarantee; reconcile or review it.
Test the Failure Paths
Run these orchestration tests with a mocked transport before using generation credits:
- Two workers claim the same image_ready row; only one obtains the lease.
- The image POST succeeds and its task ID is saved; a restart resumes polling.
- The connection drops during an image POST; the row becomes image_submit_unknown.
- Image polling reaches its local deadline; no second image POST occurs.
- An image completes without image_urls; the video POST never runs.
- The image URL is saved, then the process dies; the next worker submits video once.
- Video polling returns failed; the error is stored and no output is published.
- The same incoming event arrives twice; the unique pipeline key returns one row.
Archive completed assets under the project’s retention policy; an output address is only a delivery field.
Frequently Asked Questions
Why not solve the race with a 60-second delay?
Generation time varies, so a delay can be too short or wasteful. Polling a saved ID checks actual state and survives restarts.
Can the worker automatically retry every failed POST?
Only when non-acceptance is certain. A timeout or dropped connection may hide a created task; use submit_unknown instead of risking a duplicate.
What makes resume different from retry?
Resume polls an existing task ID. Retry creates a new job. Replace only after a terminal failure and an explicit policy decision.