Skip to content

[Hackathon] fix(test): retrying create-batch --run or a several-id test run with the same idempotency key starts (and bills) every run again #492

Closed

Description

@Shubhdeep12

Verified against (commit SHA or released version)

988c035

Discord Username / User ID

shubhdeep_chhabra

What does this improvement do?

--idempotency-key is documented as the way to retry safely: test create-batch --help says
"pin one yourself for safe retries", and test run --help says "opaque key for safe retries … Auto-generated
keys print to stderr". Retrying with the same key is supposed to make the server return the runs the first
attempt already started. On two batch paths the CLI does not send the same keys on the retry, so the server
cannot recognise the repeat and starts every run again. Each frontend run costs credits.

1. test create-batch --run (src/commands/test.ts:4387). Each trigger gets
cli-batch-run-<random UUID>, even when --idempotency-key K is passed. On a retry with K the batch create
is recognised as a repeat (same test ids come back), but every run trigger carries a new random key, so all of
them are started a second time. The sibling test create --run already gets this right: it derives
<K>:run (test.ts:1626).

2. Several-id test run through a tunnel (--local <port>, a saved loopback environment, or
--all --local; runTestRunLocalBatch, test.ts:11253-11261). With --idempotency-key K the keys are
K:<testId>, which is correct. Without it, the CLI prints one auto-minted key, uses it as-is for the
first test only, and gives every other test a random key that is never shown. Retrying with the printed
key produces <printed>:<testId> for every test, which matches none of the keys sent the first time.

When this bites: a CI step is killed or times out after the server accepted the runs, and the CI retry (or a
coding agent following the help text) runs the same command again with the same key.

Steps to reproduce

A fake API that implements idempotency the way the real one does (a repeated key returns the stored response;
a new key on a trigger starts a new run), then the built CLI run twice with the same key:

$ testsprite --endpoint-url http://127.0.0.1:48911 --output json test create-batch \
    --plans plans3.jsonl --run --no-wait --skip-preflight --idempotency-key ci-build-42   # twice

runs started on the server: 6
  POST /tests/test_f1/runs key=cli-batch-run-8a37b462-…
  POST /tests/test_f2/runs key=cli-batch-run-a6cb2c48-…
  POST /tests/test_f3/runs key=cli-batch-run-e224ebd4-…
  POST /tests/test_f1/runs key=cli-batch-run-fb3b9132-…   ← retry: new keys, 3 more runs
  POST /tests/test_f2/runs key=cli-batch-run-9b1ba549-…
  POST /tests/test_f3/runs key=cli-batch-run-e5186529-…

For path 2, the keys sent by test run a b c --local <port> and by the retry with the printed key:

test first attempt retry with printed key
a cli-run-48f9… (printed) cli-run-48f9…:a
b cli-run-abb9… (never printed) cli-run-48f9…:b
c cli-run-335a… (never printed) cli-run-48f9…:c
Fake API used above (Node, no dependencies)
import http from 'node:http';
const store = new Map(); let runsStarted = 0; const log = [];
const send = (res, status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
http.createServer((req, res) => {
  let raw = ''; req.on('data', c => (raw += c)); req.on('end', () => {
    const p = new URL(req.url, 'http://x').pathname.replace('/api/cli/v1', '');
    const key = req.headers['idempotency-key'];
    if (p === '/__stats') return send(res, 200, { runsStarted, log });
    log.push(`${req.method} ${p}${key ? ' key=' + key : ''}`);
    if (key && store.has(key)) return send(res, 200, store.get(key));
    let body;
    if (req.method === 'POST' && p === '/tests/batch')
      body = { results: ['test_f1', 'test_f2', 'test_f3'].map((testId, i) => ({ specIndex: i, testId, status: 'created' })), summary: { total: 3, created: 3, failed: 0 } };
    else if (req.method === 'POST' && /^\/tests\/[^/]+\/runs$/.test(p))
      body = { runId: `run_${++runsStarted}`, status: 'queued', enqueuedAt: new Date().toISOString(), codeVersion: 'v1', targetUrl: 'https://example.com' };
    else return send(res, 404, { error: { code: 'NOT_FOUND', message: 'no route', nextAction: '', requestId: 'r', details: {} } });
    if (key) store.set(key, body);
    send(res, 200, body);
  });
}).listen(48911);

Details / implementation notes

  • Derive every per-test trigger key from the one key the user has (passed or printed), the same way the code
    already derives <key>:run, <key>:chunkN and the test-list group keys:
    • create-batch --run: <createKey>:run:<testId> (never the create key itself, which would replay the
      create response);
    • several-id test run: <key>:<testId> for every test, auto-minted keys included.
  • Keep the derived key within 256 characters by trimming the base, as the existing derivations do.
  • Tests: a retry with the same key (passed or printed) re-sends identical trigger keys; different tests still
    get distinct keys. No new dependency.

Confirmations

  • I have searched existing issues and this is not a duplicate.
  • I have provided my Discord identity above for reward coordination.

Activity

  1. Shubhdeep12 commented on Oct 17, 2026

    @Shubhdeep12
    ContributorAuthor

    /assign

  2. testsprite-hob commented on Oct 17, 2026

    @testsprite-hob

    Thanks for the interest, @Shubhdeep12! This issue hasn't been accepted for implementation yet — see the triage comment (a maintainer will label it accepted once it is). Feel free to check back, or ping us on Discord if it's been quiet.

  3. zeshi-du commented on Oct 20, 2026

    @zeshi-du
    Contributor

    Thanks @Shubhdeep12. Accepted; PR #494 is the right scope, so please /assign and continue there.

    • Derive every per-test trigger key from the user's key, including the auto-minted one (keep printing it): <key>:run:<testId> for create-batch --run; <key>:<testId> for multi-id test run, or <key>:<clientId>:<testId> through a tunnel. Keeping the client id is fine; document in DOCUMENTATION.md that a retry via a new tunnel starts new runs.
    • One shared helper for derivation and the 256-char limit; digest suffix for overlong keys, not tail trimming.
    • Additive only: no exit-code or JSON changes, no other refactors.
    • Mock-suite tests that fail before the fix.
    • PR text: the server normally refuses a second run on a running test, so double billing needs a retry after the first runs finished.

    Tracked internally as DEV-2184.

  4. added
    acceptedTriaged and accepted — a PR for this will be reviewed
    and removed
    needs-triageNew issue/PR awaiting first triage
    on Oct 20, 2026
  5. Shubhdeep12 commented on Oct 20, 2026

    @Shubhdeep12
    ContributorAuthor

    /assign

  6. testsprite-hob commented on Oct 20, 2026

    @testsprite-hob

    Assigned to @Shubhdeep12. Thanks for taking this on. (2 more slots available.)

    Your open assigned issues (1/3):
    (none)

  7. added a commit that references this issue on Nov 10, 2026
    3cf57fd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

acceptedTriaged and accepted — a PR for this will be reviewedhackathonCLI hackathon submissionsin-progressAssigned and actively being worked on

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions