fix(midjourney): poll job state before timeout (#2383)

This commit is contained in:
jakevin
2026-08-24 22:32:04 +08:00
committed by GitHub
parent d667222c90
commit dc0a1ef1f5
2 changed files with 67 additions and 13 deletions
+18 -11
View File
@@ -399,6 +399,13 @@ export async function waitForSubmittedJobAfter(page, userId, prompt, baselineIds
return (await waitForSubmittedJobsAfter(page, userId, prompt, baselineIds, timeoutSeconds, submittedAtMs, 1))[0];
}
async function waitForNextPoll(page, deadline, intervalSeconds) {
const remainingSeconds = (deadline - Date.now()) / 1000;
if (remainingSeconds <= 0) return false;
await page.wait(Math.min(intervalSeconds, remainingSeconds));
return Date.now() < deadline;
}
export async function waitForSubmittedJobsAfter(
page,
userId,
@@ -415,7 +422,7 @@ export async function waitForSubmittedJobsAfter(
const historyPageSize = Math.min(100, Math.max(20, expectedCount));
let ambiguousIds = [];
let consecutivePollFailures = 0;
while (Date.now() < deadline) {
do {
let recent = [];
try {
recent = await fetchHistory(page, userId, historyPageSize);
@@ -424,7 +431,7 @@ export async function waitForSubmittedJobsAfter(
if (error instanceof AuthRequiredError) throw error;
consecutivePollFailures += 1;
if (consecutivePollFailures >= 3) throw error;
await page.wait(1.5);
if (!(await waitForNextPoll(page, deadline, 1.5))) break;
continue;
}
const newRows = recent.filter((row) => {
@@ -449,8 +456,8 @@ export async function waitForSubmittedJobsAfter(
.map((row) => String(row.id).toLowerCase());
}
if (matching.length > expectedCount) ambiguousIds = matching.map((row) => String(row.id).toLowerCase());
await page.wait(1.5);
}
if (!(await waitForNextPoll(page, deadline, 1.5))) break;
} while (true);
if (ambiguousIds.length > expectedCount) {
throw new CommandExecutionError(
`Midjourney submission is ambiguous; ${ambiguousIds.length} new jobs matched the prompt`,
@@ -468,7 +475,7 @@ export async function waitForDerivedJob(page, userId, parentJobId, baselineIds,
const deadline = Date.now() + timeoutSeconds * 1000;
let candidates = [];
let consecutivePollFailures = 0;
while (Date.now() < deadline) {
do {
let recent;
try {
recent = await fetchHistory(page, userId, 50);
@@ -477,7 +484,7 @@ export async function waitForDerivedJob(page, userId, parentJobId, baselineIds,
if (error instanceof AuthRequiredError) throw error;
consecutivePollFailures += 1;
if (consecutivePollFailures >= 3) throw error;
await page.wait(1.5);
if (!(await waitForNextPoll(page, deadline, 1.5))) break;
continue;
}
candidates = recent.filter((row) => {
@@ -491,8 +498,8 @@ export async function waitForDerivedJob(page, userId, parentJobId, baselineIds,
&& enqueuedAt >= submittedAtMs - 5000;
});
if (candidates.length === 1) return String(candidates[0].id).toLowerCase();
await page.wait(1.5);
}
if (!(await waitForNextPoll(page, deadline, 1.5))) break;
} while (true);
if (candidates.length > 1) {
throw new CommandExecutionError(
`Midjourney action is ambiguous; ${candidates.length} derived jobs matched parent ${parentJobId}`,
@@ -505,7 +512,7 @@ export async function waitForDerivedJob(page, userId, parentJobId, baselineIds,
export async function waitForCompletedJob(page, jobId, timeoutSeconds) {
const deadline = Date.now() + timeoutSeconds * 1000;
let lastStatus = 'unknown';
while (Date.now() < deadline) {
do {
const job = await fetchJobStatus(page, jobId, { allowMissing: true });
if (job) {
lastStatus = String(job.current_status || job.status || 'unknown').toLowerCase();
@@ -514,8 +521,8 @@ export async function waitForCompletedJob(page, jobId, timeoutSeconds) {
throw new CommandExecutionError(`Midjourney job ${jobId} ended with status "${lastStatus}"`);
}
}
await page.wait(2);
}
if (!(await waitForNextPoll(page, deadline, 2))) break;
} while (true);
throw new TimeoutError(
`Midjourney job ${jobId} (last status: ${lastStatus})`,
timeoutSeconds,
+49 -2
View File
@@ -163,15 +163,23 @@ it('submission correlation tolerates UI-only local reference weights omitted fro
)).toEqual([jobId]);
});
it('submission correlation fails closed on ambiguous duplicate jobs', async () => {
it('submission correlation polls once and preserves ambiguity when its deadline is already reached', async () => {
const submittedAt = Date.parse('2026-07-30T00:00:00Z');
const page = fakePage({ history: [
let now = 1_000;
vi.spyOn(Date, 'now').mockImplementation(() => now++);
const basePage = fakePage({ history: [
{ id: '33333333-3333-3333-3333-333333333333', full_command: 'duplicate prompt --v 8.2', enqueue_time: '2026-07-30T00:00:01Z' },
{ id: '44444444-4444-4444-4444-444444444444', full_command: 'duplicate prompt --v 8.2', enqueue_time: '2026-07-30T00:00:02Z' },
] });
const page = {
fetchJson: vi.fn(basePage.fetchJson),
wait: vi.fn(),
};
await expect(waitForSubmittedJobsAfter(
page, 'user', 'duplicate prompt --v 8.2', new Set(), 0.001, submittedAt, 1,
)).rejects.toThrow(/ambiguous/);
expect(page.fetchJson).toHaveBeenCalledTimes(1);
expect(page.wait).not.toHaveBeenCalled();
});
it('submit response correlation uses exact returned job ids and rejects ambiguity', () => {
@@ -210,6 +218,45 @@ it('derived-job and completion correlation use parent and lifecycle fields', asy
expect((await waitForCompletedJob(page, child, 1)).current_status).toBe('completed');
});
it('all job pollers cap the final wait and do not poll again at the deadline', async () => {
let now = 1_000;
vi.spyOn(Date, 'now').mockImplementation(() => now);
const timeoutPage = () => {
const basePage = fakePage();
return {
fetchJson: vi.fn(basePage.fetchJson),
wait: vi.fn(async (seconds) => { now += seconds * 1000; }),
};
};
const cases = [
(page) => waitForSubmittedJobsAfter(page, 'user', 'missing prompt', new Set(), 0.25, now, 1),
(page) => waitForDerivedJob(page, 'user', JOB, new Set(), 0.25, now),
(page) => waitForCompletedJob(page, JOB, 0.25),
];
for (const poll of cases) {
const page = timeoutPage();
await expect(poll(page)).rejects.toThrow(/timed out/);
expect(page.fetchJson).toHaveBeenCalledTimes(1);
expect(page.wait).toHaveBeenCalledTimes(1);
expect(page.wait).toHaveBeenCalledWith(0.25);
}
});
it('polling propagates an aborted wait without making a second request', async () => {
const abort = Object.assign(new Error('polling aborted'), { name: 'AbortError' });
const basePage = fakePage();
const page = {
fetchJson: vi.fn(basePage.fetchJson),
wait: vi.fn().mockRejectedValue(abort),
};
await expect(waitForSubmittedJobsAfter(
page, 'user', 'missing prompt', new Set(), 5, Date.now(), 1,
)).rejects.toBe(abort);
expect(page.fetchJson).toHaveBeenCalledTimes(1);
expect(page.wait).toHaveBeenCalledTimes(1);
});
it('submission polling propagates authentication failures immediately', async () => {
let polls = 0;
const page = {