Skip to content

Commit 5ebcba8

Browse files
Merge pull request #6 from crypticsaiyan/fix/batch-run-concurrency
fix(test): prevent batch-run scheduler from serializing after first wave
2 parents 18f6e6e + fc92877 commit 5ebcba8

2 files changed

Lines changed: 120 additions & 36 deletions

File tree

src/commands/test.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6694,6 +6694,103 @@ describe('runCreateBatch', () => {
66946694
).resolves.toBeDefined();
66956695
});
66966696

6697+
// Regression test: create-batch --run must keep launching new triggers
6698+
// up to --max-concurrency as slots free up, not collapse to serial
6699+
// after the first wave. Uses equal-delay trigger responses so the
6700+
// first three jobs settle in the same microtask batch, the exact
6701+
// condition that exposed the bug (race only reacts to one settlement,
6702+
// then blocks the scheduler on the whole next job before moving on).
6703+
it('--run keeps concurrency at --max-concurrency for tail jobs, not just the first wave', async () => {
6704+
const { credentialsPath } = makeCreds();
6705+
const specs = Array.from({ length: 6 }, (_, i) => ({ ...FE_SPEC, name: `spec-${i}` }));
6706+
const plansFile = writePlansJsonl(specs);
6707+
const CREATE_RESP = {
6708+
results: specs.map((_, i) => ({
6709+
specIndex: i,
6710+
testId: `test_tail_${i}`,
6711+
status: 'created' as const,
6712+
})),
6713+
summary: { total: 6, created: 6, failed: 0 },
6714+
};
6715+
const TRIGGER_DELAY_MS = 60;
6716+
const limit = 3;
6717+
let activeCount = 0;
6718+
let triggerCallIndex = 0;
6719+
const activeAtStart: number[] = [];
6720+
6721+
type FetchInput2 = Parameters<typeof globalThis.fetch>[0];
6722+
const fetchImpl = (async (input: FetchInput2) => {
6723+
const url =
6724+
typeof input === 'string'
6725+
? input
6726+
: input instanceof URL
6727+
? input.toString()
6728+
: (input as { url: string }).url;
6729+
if (url.includes('/tests/batch')) {
6730+
return new Response(JSON.stringify(CREATE_RESP), {
6731+
status: 200,
6732+
headers: { 'content-type': 'application/json' },
6733+
});
6734+
}
6735+
if (url.includes('/runs')) {
6736+
const callIdx = triggerCallIndex++;
6737+
activeCount++;
6738+
activeAtStart[callIdx] = activeCount;
6739+
await new Promise(resolve => setTimeout(resolve, TRIGGER_DELAY_MS));
6740+
activeCount--;
6741+
return new Response(
6742+
JSON.stringify({
6743+
runId: `run_tail_${callIdx}`,
6744+
status: 'queued' as const,
6745+
enqueuedAt: '2026-06-09T10:00:00.000Z',
6746+
codeVersion: 'v1',
6747+
targetUrl: 'https://example.com',
6748+
}),
6749+
{ status: 200, headers: { 'content-type': 'application/json' } },
6750+
);
6751+
}
6752+
return new Response(
6753+
JSON.stringify({
6754+
error: { code: 'NOT_FOUND', message: 'not found', nextAction: '', requestId: 'r1' },
6755+
}),
6756+
{ status: 404, headers: { 'content-type': 'application/json' } },
6757+
);
6758+
}) as typeof globalThis.fetch;
6759+
6760+
try {
6761+
await runCreateBatch(
6762+
{
6763+
profile: 'default',
6764+
output: 'json',
6765+
debug: false,
6766+
plans: plansFile,
6767+
run: true,
6768+
wait: false,
6769+
dryRun: false,
6770+
maxConcurrency: limit,
6771+
},
6772+
{
6773+
credentialsPath,
6774+
fetchImpl: fetchImpl as ReturnType<typeof makeFetch>,
6775+
stdout: () => undefined,
6776+
stderr: () => undefined,
6777+
},
6778+
);
6779+
} catch {
6780+
// CLIError exit 1 expected: trigger status is 'queued', not 'passed'.
6781+
}
6782+
6783+
expect(activeAtStart).toHaveLength(6);
6784+
// First wave fills up to the limit; true under the bug too.
6785+
expect(Math.max(...activeAtStart.slice(0, limit))).toBe(limit);
6786+
// Tail jobs (index >= limit) must ALSO reach the concurrency limit.
6787+
// Under the bug, the scheduler blocks on each whole job after the
6788+
// first wave, so every tail job launches alone (active === 1).
6789+
for (const snapshot of activeAtStart.slice(limit)) {
6790+
expect(snapshot).toBe(limit);
6791+
}
6792+
});
6793+
66976794
// Per codex round-1 P2: a 200 OK with `summary.created === 0` on a
66986795
// non-empty batch must not exit 0. Without this, a misconfigured
66996796
// batch job (every spec invalid) silently lands nothing in DDB while

src/commands/test.ts

Lines changed: 23 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2753,43 +2753,30 @@ async function runBatchRun(
27532753
};
27542754
}
27552755

2756-
// Bounded concurrency fan-out using a semaphore pattern.
2757-
// We process testIds one slot at a time up to the concurrency limit.
2758-
// This avoids pulling in p-limit; the logic is simple enough inline.
2759-
const remaining = [...testIds];
2760-
const inFlight = new Set<Promise<CliBatchRunResult>>();
2761-
2762-
async function drainOne(): Promise<void> {
2763-
const testId = remaining.shift();
2764-
if (testId === undefined) return;
2765-
const p = triggerOne(testId).then(result => {
2766-
batchRunResults.push(result);
2767-
inFlight.delete(p);
2768-
return result;
2769-
});
2770-
inFlight.add(p);
2771-
await p;
2772-
}
2773-
2774-
// Fill up to concurrencyLimit slots, then drain one before adding
2775-
// each new testId so we never exceed the limit.
2776-
const initial = Math.min(concurrencyLimit, testIds.length);
2777-
const startPromises: Promise<void>[] = [];
2778-
for (let i = 0; i < initial; i++) {
2779-
startPromises.push(drainOne());
2780-
}
2781-
// Process remaining items as slots free up.
2782-
while (remaining.length > 0) {
2783-
// Wait for any in-flight slot to free up.
2784-
if (inFlight.size > 0) {
2785-
await Promise.race(inFlight);
2786-
}
2787-
if (remaining.length > 0 && inFlight.size < concurrencyLimit) {
2788-
await drainOne();
2756+
// Bounded concurrency fan-out: launch up to concurrencyLimit jobs, then
2757+
// launch the next one as each finishes. Mirrors the startNext() pattern
2758+
// used by the other fan-outs in this file (e.g. pollFreshAccepted below).
2759+
let nextIdx = 0;
2760+
let inFlight = 0;
2761+
2762+
await new Promise<void>((resolve, reject) => {
2763+
function startNext(): void {
2764+
while (inFlight < concurrencyLimit && nextIdx < testIds.length) {
2765+
const testId = testIds[nextIdx++]!;
2766+
inFlight++;
2767+
triggerOne(testId)
2768+
.then(result => {
2769+
batchRunResults.push(result);
2770+
inFlight--;
2771+
startNext();
2772+
if (inFlight === 0 && nextIdx >= testIds.length) resolve();
2773+
})
2774+
.catch(reject);
2775+
}
27892776
}
2790-
}
2791-
// Wait for all in-flight to finish.
2792-
await Promise.all([...inFlight, ...startPromises]);
2777+
startNext();
2778+
if (testIds.length === 0) resolve();
2779+
});
27932780

27942781
// Sort by testId order (same as input order for stable output).
27952782
batchRunResults.sort((a, b) => testIds.indexOf(a.testId) - testIds.indexOf(b.testId));

0 commit comments

Comments
 (0)