mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
fix(midjourney): poll job state before timeout (#2383)
This commit is contained in:
+18
-11
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user