Repository navigation
[Hackathon] fix(create-batch): throttle expiry dispatches a run after the wait deadline #485
Description
Verified against (commit SHA or released version)
commit 988c0355bb05631e9c6c1785e443937ff2a88c0e (current public main, v0.14.0), Node v22.23.2, macOS. Rebuilt and rechecked main on 2026-10-17.
Discord Username / User ID
luckyzd.
What does this improvement do?
Prevent test create-batch --run --wait from starting a new server-side run after that spec's wait budget has expired when a client-side rate-throttle slot becomes available at the same boundary.
A normal triggering combination is a full 50-spec batch, --max-concurrency 50 --timeout 60, and one transient HTTP 429 with Retry-After: 1. The first 50 trigger attempts fill the CLI's 50/minute window. The rate-limited member's retry waits for a slot; that sleep is correctly clamped to its remaining wait budget. On wake-up, the old slots have expired, so rateThrottle.acquire() returns 0. The CLI falls out of the throttle loop directly into a new POST without rechecking the deadline. It then accepts the new run ID and immediately reports timeout without polling it.
This can start billable work that the invocation has no remaining budget to observe. The run ID is retained in this case; the defect is the late dispatch, not lost receipts or a green exit. The first 429 did not accept a run, and the retry uses the same idempotency key, so idempotency does not prevent this newly accepted work.
Actual: the expired member sends a second POST /tests/{id}/runs, receives run_test_49, then emits status: "timeout" / Timed out after 60s before polling run run_test_49. No GET for that run occurs. The mixed batch exits 1 (49 passed + 1 timeout).
Expected: after the wait budget is exhausted, do not send another trigger POST. Retain the 49 completed members and report the untriggered member as timed out, as the existing 2-second-budget control already does. If budget remains, dispatch and poll normally.
Details / implementation notes
This is an offline CLI reproduction, not a reported hosted incident. Fixtures use the repository's existing transient-429 envelope and standard trigger/run response contracts. No real credentials, hosted runs or credit spending were involved. Product source is unchanged.
Root cause: in src/commands/test.ts, triggerOne creates specDeadlineMs at line 4397, checks it before entering the throttle loop at 4419, and checks it inside the loop only when acquire() returns a positive delay (4437–4458). When acquire() returns 0 after a deadline-clamped sleep, both checks have been bypassed and the POST at 4466 is issued. src/lib/rate-throttle.ts:64–69 legitimately frees expired slots; acquiring a slot is not evidence that the spec still has time. The later remaining-budget guard before polling cannot undo the dispatch.
The existing tests explicitly require that no trigger fires after the deadline (test.create-batch-run.spec.ts, ROUND-2 (c)), but exercise expiry during the outer 429 backoff. The MAJOR 2 throttle test uses a 2-second budget, which expires while all 60-second slots are still occupied. Neither covers the slot becoming available at the deadline.
Evidence and controls:
| Input | Last member's trigger attempts | Last member polled? | Outcome |
|---|---|---|---|
| 50 specs, 60s budget, one 429 | 2; retry at exhausted budget | No | New run ID returned, immediately timeout |
| Repeat of that real-timer case | 2 | No | Same outcome |
| 50 specs, 2s budget, one 429 | 1 | No | No new run ID; correctly times out before acquiring slot |
| 50 specs, 61s budget, one 429 | 2 | Yes | Passed, exit 0 |
| 50 specs, 60s budget, no 429 | 1 | Yes | Passed, exit 0 |
| 49 specs, 60s budget, one 429 | 2 | Yes | Spare slot; passed after ~1s, exit 0 |
The real-timer script below runs the actual Commander entry, HTTP client, 60-second throttle and poller. Its sleep hook uses ordinary setTimeout and only records requested/settled times; there is no fake clock or shortened rate window. The two affected invocations took 60.023s and 60.027s. Their second POSTs occurred at/after the recorded deadline-clamped wake-up; normal controls took ~2s, ~60.074s, milliseconds, and ~1s respectively. Timing near a deadline can vary, so the script records that observation instead of assuming every OS scheduling run must hit it.
The deterministic companion advances Date.now by exactly the requested sleeps, following the injection style of the existing tests. It removes timing ambiguity: sleeps [1000, 59000], trigger times [0, 60000], budget 60000, second trigger accepted, zero polls. It uses serial concurrency so the first 49 members have already passed before advancing time. Its 2s/61s/49-member controls pass. Controlled time is explicitly separate from real elapsed-time evidence.
Validation: build passed; test.flaky.spec.ts, test.plan.spec.ts, test.cancel.spec.ts, test.create-batch-run.spec.ts, and rate-throttle.spec.ts: 162 tests passed across 5 files. These passing tests do not mean the new boundary is already covered.
Duplicate check (open and closed Issues/PRs, including review discussions): searched create-batch + throttle/deadline/after timeout, rateThrottle, and acquire + timeout; read the related PRs and discussions.
- [Hackathon] fix(create-batch): rounding remaining wait budget down causes premature timeouts #480 concerns flooring the remaining poll budget, which causes an early timeout after a valid dispatch. Here no poll is started; a new POST is sent with an exhausted budget. Changing that rounding does not fix this branch.
- PR fix(test): stop run --all --wait from polling queued runs past the shared deadline #130 fixes
test run --allstarting GET polls after a shared deadline. This iscreate-batchstarting a POST that creates a run after a per-spec deadline. - [Hackathon] fix(http): retry backoff ignores caller abort signals and overruns test wait --timeout #403 / PR fix(http): honor caller abort signals during retry backoff #406 concern HTTP retry sleeps ignoring caller abort signals. Here the outer throttle sleep already honors the remaining budget; the missing guard is after it wakes and acquires a slot.
- [Hackathon] fix(test): batch retry sleep exits on signal before preserving acknowledged IDs #426 concerns interruption during a retry sleep and receipt preservation; this repro uses no signal.
- PRs fix(batch): classify RequestTimeoutError as timeout in --all --wait fan-out #135/fix(batch): classify RequestTimeoutError as timeout in --all --wait fan-out #154 classify request timeouts; fix(test): create-batch --run without --wait exits 0 on a fully successful dispatch #267 fixes no-wait exit status. No request times out here and
--waitis enabled. - PR fix(test): prevent batch-run scheduler from serializing after first wave #6 changes fan-out scheduling; its discussion has no throttle-deadline report. No matching issue or PR was found in the searches above.
Suggested scope after triage: recheck the per-spec deadline immediately before issuing the trigger after slot acquisition. Add the exact 60-second-slot-expiry case alongside the existing 2-second throttle test; preserve normal retries, idempotency keys, and already-completed results. No implementation PR is attached before triage/assignment.
Run the reproductions: save the two scripts below under the shown names. With dependencies installed in this checkout, run npm run build, then:
node reproduce-throttle-deadline-controlled-clock.mjs /path/to/testsprite-cli
node reproduce-create-batch-throttle-deadline.mjs /path/to/testsprite-cliThe second script takes about 61 seconds because it keeps the real rate window. Both emit JSON evidence and remove only their own temporary fixture directories.
reproduce-throttle-deadline-controlled-clock.mjs
import assert from 'node:assert/strict';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
// Deterministic companion to reproduce-create-batch-throttle-deadline.mjs.
// Same injection style as the repository's MAJOR 2 / ROUND-2 tests: Date.now
// advances by the exact requested sleep, with no jump past the deadline.
// This is a controlled-clock offline command-handler test, not real elapsed
// time and not hosted observation. HTTP and clock are the only substitutions.
const root = resolve(process.argv[2] ?? 'work/testsprite-cli');
const { runCreateBatch } = await import(pathToFileURL(join(root, 'dist/commands/test.js')));
const temp = await mkdtemp(join(tmpdir(), 'testsprite-throttle-clock-'));
const realNow = Date.now;
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
status, headers: { 'content-type': 'application/json', ...headers },
});
const results = [];
try {
for (const c of [
{ count: 50, timeout: 60, expectedTriggers: 2, expectedStatus: 'timeout' },
{ count: 50, timeout: 2, expectedTriggers: 1, expectedStatus: 'timeout' },
{ count: 50, timeout: 61, expectedTriggers: 2, expectedStatus: 'passed' },
{ count: 49, timeout: 60, expectedTriggers: 2, expectedStatus: 'passed' },
]) {
const start = Date.parse('2026-10-17T00:00:00Z'); let now = start;
Date.now = () => now;
const ids = Array.from({ length: c.count }, (_, i) => 'test_' + i), lastId = ids.at(-1);
const plans = join(temp, `${c.count}-${c.timeout}.jsonl`);
await writeFile(plans, ids.map(id => JSON.stringify({ projectId: 'project', type: 'frontend', name: id,
planSteps: [{ type: 'assertion', description: 'Home is visible' }] })).join('\n') + '\n');
const stdout = [], trace = [], sleeps = [], attempts = new Map(), acceptedAt = new Map();
let exitCode = 0;
try {
await runCreateBatch({ profile: 'default', output: 'json', debug: false, dryRun: false,
plans, run: true, wait: true, maxConcurrency: 1, timeoutSeconds: c.timeout }, {
env: { TESTSPRITE_API_KEY: 'sk-user-offline-fixture', TESTSPRITE_API_URL: 'https://mock.example.com' },
credentialsPath: join(temp, 'absent'), stdout: s => stdout.push(s), stderr: () => {},
sleep: ms => { sleeps.push(ms); now += ms; return Promise.resolve(); },
fetchImpl: async (input, init = {}) => {
const u = new URL(typeof input === 'string' ? input : input.url ?? input.href);
assert.equal(u.hostname, 'mock.example.com');
const path = u.pathname.replace('/api/cli/v1', ''), method = init.method ?? 'GET';
trace.push({ path, method, elapsedMs: now - start });
if (path === '/projects/project/env') return json({ environments: [
{ name: 'default', url: 'https://example.com', isDefault: true, isTemporary: false },
] });
if (path === '/tests/batch') return json({ results: ids.map((testId, specIndex) =>
({ testId, specIndex, status: 'created' })), summary: { total: ids.length, created: ids.length, failed: 0 } });
const trigger = /^\/tests\/(test_\d+)\/runs$/.exec(path);
if (trigger) {
const id = trigger[1], n = (attempts.get(id) ?? 0) + 1; attempts.set(id, n);
if (id === lastId && n === 1) return json({ error: { code: 'RATE_LIMITED',
message: 'Run trigger rate limit exceeded: 60 triggers per minute per key.',
nextAction: 'Wait.', requestId: 'fixture', details: { retryAfterSeconds: 1 } } },
429, { 'retry-after': '1' });
acceptedAt.set(id, now);
return json({ runId: 'run_' + id, status: 'queued', codeVersion: 'v1',
enqueuedAt: new Date(now).toISOString(), targetUrl: 'https://example.com' });
}
const poll = /^\/runs\/run_(test_\d+)$/.exec(path);
if (poll) {
const stamp = new Date(acceptedAt.get(poll[1])).toISOString();
return json({ runId: 'run_' + poll[1], testId: poll[1], projectId: 'project', userId: 'fixture',
status: 'passed', source: 'cli', createdAt: stamp, startedAt: stamp, finishedAt: stamp,
codeVersion: 'v1', targetUrl: 'https://example.com', createdFrom: 'cli', failedStepIndex: null,
failureKind: null, error: null, videoUrl: null,
stepSummary: { total: 1, completed: 1, passedCount: 1, failedCount: 0 } });
}
throw Error('Unexpected route ' + path);
},
});
} catch (e) { exitCode = e.exitCode ?? 1; }
const payload = JSON.parse(stdout.join('\n'));
const last = payload.results.find(r => r.testId === lastId);
assert.equal(payload.results.filter(r => r.testId !== lastId && r.status === 'passed').length, c.count - 1);
const triggers = trace.filter(r => r.path === '/tests/' + lastId + '/runs');
const polls = trace.filter(r => r.path === '/runs/run_' + lastId);
assert.equal(triggers.length, c.expectedTriggers); assert.equal(last.status, c.expectedStatus);
assert.equal(polls.length, c.expectedStatus === 'passed' ? 1 : 0);
if (c.count === 50 && c.timeout === 60) {
assert.deepEqual(sleeps, [1000, 59000]);
assert.deepEqual(triggers.map(x => x.elapsedMs), [0, 60000]);
assert.equal(last.runId, 'run_test_49');
}
results.push({ ...c, exitCode, last, sleeps, lastTriggers: triggers, lastPolls: polls.length });
Date.now = realNow;
}
console.log(JSON.stringify({ evidence: 'offline controlled-clock handler; Date.now advances exactly by requested sleep', results }, null, 2));
} finally { Date.now = realNow; await rm(temp, { recursive: true, force: true }); }reproduce-create-batch-throttle-deadline.mjs
import assert from 'node:assert/strict';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
// Real 60-second rate window and real timers. No clock/sleep/throttle changes.
// Actual CLI parser, client, rate throttle and poller; only HTTP is offline.
const root = resolve(process.argv[2] ?? 'work/testsprite-cli');
const require = createRequire(join(root, 'package.json'));
const { Command } = require('commander');
const { createTestCommand } = await import(pathToFileURL(join(root, 'dist/commands/test.js')));
const temp = await mkdtemp(join(tmpdir(), 'testsprite-throttle-deadline-'));
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
status, headers: { 'content-type': 'application/json', ...headers },
});
const cases = [
{ name: 'short-budget-no-late-trigger-control', count: 50, timeout: 2, retry: true },
{ name: 'window-boundary-expired-retry', count: 50, timeout: 60, retry: true },
{ name: 'window-boundary-expired-retry-repeat', count: 50, timeout: 60, retry: true },
{ name: 'extra-budget-retry-control', count: 50, timeout: 61, retry: true },
{ name: 'no-rate-limit-control', count: 50, timeout: 60, retry: false },
{ name: 'spare-throttle-slot-control', count: 49, timeout: 60, retry: true },
];
try {
const results = await Promise.all(cases.map(async c => {
const ids = Array.from({ length: c.count }, (_, i) => 'test_' + i);
const lastId = ids.at(-1), plans = join(temp, c.name + '.jsonl');
await writeFile(plans, ids.map(id => JSON.stringify({ projectId: 'project', type: 'frontend',
name: id, planSteps: [{ type: 'assertion', description: 'Home is visible' }] })).join('\n') + '\n');
const stdout = [], stderr = [], trace = [], sleeps = [], first = new Map(), acceptedAt = new Map(), attempts = new Map();
const deps = {
env: { TESTSPRITE_API_KEY: 'sk-user-offline-fixture', TESTSPRITE_API_URL: 'https://mock.example.com' },
credentialsPath: join(temp, 'absent'), stdout: x => stdout.push(x), stderr: x => stderr.push(x),
// Same real setTimeout used by the CLI's default sleep. Record an upper
// bound for the deadline at a clamped sleep, without changing clock/time.
sleep: ms => new Promise(resolve => {
const s = { startedAt: Date.now(), ms }; sleeps.push(s);
setTimeout(() => { s.settledAt = Date.now(); resolve(); }, ms);
}),
fetchImpl: async (input, init = {}) => {
const u = new URL(typeof input === 'string' ? input : input.url ?? input.href);
assert.equal(u.hostname, 'mock.example.com');
const path = u.pathname.replace('/api/cli/v1', ''), method = init.method ?? 'GET';
const event = { method, path, at: Date.now() }; trace.push(event);
if (path === '/projects/project/env') return json({ environments: [
{ name: 'default', url: 'https://example.com', isDefault: true, isTemporary: false },
] });
if (path === '/tests/batch' && method === 'POST') return json({
results: ids.map((testId, specIndex) => ({ specIndex, testId, status: 'created' })),
summary: { total: ids.length, created: ids.length, failed: 0 },
});
const trigger = /^\/tests\/(test_\d+)\/runs$/.exec(path);
if (trigger && method === 'POST') {
const id = trigger[1], attempt = (attempts.get(id) ?? 0) + 1;
attempts.set(id, attempt); if (!first.has(id)) first.set(id, event.at);
Object.assign(event, { attempt, idempotencyKey: new Headers(init.headers).get('idempotency-key'),
sinceFirstTriggerMs: event.at - first.get(id) });
if (c.retry && id === lastId && attempt === 1) return json({ error: {
code: 'RATE_LIMITED', message: 'Run trigger rate limit exceeded: 60 triggers per minute per key.',
nextAction: 'Wait and retry.', requestId: 'fixture', details: { retryAfterSeconds: 1 },
} }, 429, { 'retry-after': '1' });
acceptedAt.set(id, event.at);
return json({ runId: 'run_' + id, status: 'queued', enqueuedAt: new Date(event.at).toISOString(),
codeVersion: 'v1', targetUrl: 'https://example.com' });
}
const poll = /^\/runs\/run_(test_\d+)$/.exec(path);
if (poll) {
const id = poll[1], stamp = new Date(acceptedAt.get(id)).toISOString();
return json({ runId: 'run_' + id, testId: id, projectId: 'project', userId: 'fixture',
status: 'passed', source: 'cli', createdAt: stamp, startedAt: stamp, finishedAt: stamp,
codeVersion: 'v1', targetUrl: 'https://example.com', createdFrom: 'cli',
failedStepIndex: null, failureKind: null, error: null, videoUrl: null,
stepSummary: { total: 1, completed: 1, passedCount: 1, failedCount: 0 } });
}
throw Error('Unexpected route ' + method + ' ' + path);
},
};
const args = ['test', 'create-batch', '--plans', plans, '--run', '--wait',
'--timeout', String(c.timeout), '--max-concurrency', '50'];
let exitCode = 0, caught;
const start = Date.now();
try { await new Command('testsprite').option('--output <mode>').addCommand(createTestCommand(deps))
.parseAsync(['--output', 'json', ...args], { from: 'user' }); }
catch (e) { exitCode = e.exitCode ?? 1; caught = { name: e.name, message: e.message, exitCode }; }
const payload = JSON.parse(stdout.join('\n'));
const triggers = trace.filter(x => x.path === '/tests/' + lastId + '/runs');
const polls = trace.filter(x => x.path === '/runs/run_' + lastId);
const last = payload.results.find(x => x.testId === lastId);
assert.equal(payload.results.length, c.count);
assert.equal(payload.results.filter(x => x.testId !== lastId && x.status === 'passed').length, c.count - 1);
const throttleSleep = sleeps.find(s => s.ms > 10000);
const clampedDeadlineUpperBound = c.name.includes('boundary') && throttleSleep
? throttleSleep.startedAt + throttleSleep.ms : null;
const lateTriggerConfirmed = clampedDeadlineUpperBound !== null && triggers.length > 1
&& triggers[1].at >= clampedDeadlineUpperBound;
if (c.name.includes('boundary')) {
// Natural Node timer jitter may wake just before or after the boundary.
// Keep either result; only a POST at/after the measured upper bound is
// evidence of a late dispatch. Do not turn timing uncertainty into proof.
assert.equal(triggers.length, 2);
assert.equal(last.runId, 'run_' + lastId);
assert.ok(['timeout', 'passed'].includes(last.status));
if (lateTriggerConfirmed) { assert.equal(polls.length, 0); assert.equal(last.status, 'timeout'); }
} else if (c.timeout === 2) {
assert.equal(triggers.length, 1); assert.equal(polls.length, 0);
assert.equal(last.status, 'timeout'); assert.equal(last.runId, '');
} else {
assert.equal(triggers.length, c.retry ? 2 : 1); assert.equal(polls.length, 1);
assert.equal(exitCode, 0); assert.equal(last.status, 'passed');
if (c.retry) assert.ok(triggers[1].sinceFirstTriggerMs < c.timeout * 1000);
}
if (triggers.length === 2) assert.equal(triggers[0].idempotencyKey, triggers[1].idempotencyKey);
return { ...c, args, exitCode, error: caught, elapsedMs: Date.now() - start,
last, lastTriggers: triggers, lastPollCount: polls.length, sleeps,
clampedDeadlineUpperBound, lateTriggerConfirmed, payload, trace, stderr };
}));
console.log(JSON.stringify({ baseline: '988c0355bb05631e9c6c1785e443937ff2a88c0e',
node: process.version, scope: 'offline HTTP only; real 60s timers; no hosted runs', results }, null, 2));
} finally { await rm(temp, { recursive: true, force: true }); }Confirmations
- I have searched existing issues and this is not a duplicate.
- I have provided my Discord identity above for reward coordination.
Activity
zeshi-du commentedon Oct 20, 2026
Thanks @luckyzd — confirmed from the code: after a deadline-clamped throttle sleep,
rateThrottle.acquire()returns 0, the loop exits, and the trigger POST goes out without re-checking the per-spec budget. Accepted.Scope:
triggerOneon thecreate-batch --run --waitpath insrc/commands/test.ts: after the throttle loop, before the POST, re-checkremainingMs()(only with--wait) and return the existing timeout result for that member without sending the POST. If budget remains, behaviour must not change.[1000, 59000], one POST, zero polls) totest.create-batch-run.spec.ts; it must fail before the fix. Keep your 2s/61s/49-spec controls.Comment /assign to claim it.
luckyzd commentedon Oct 20, 2026
/assign
testsprite-hob commentedon Oct 20, 2026
Assigned to @luckyzd. Thanks for taking this on. (0 more slots available.)
Your open assigned issues (3/3):