Skip to content

[Hackathon] Concurrent failure-bundle writers can return success with mixed run artifacts #501

Open

Description

@lxcario

Verified against (commit SHA or released version)

988c035 (v0.14.0), Node 24.19.0, Windows 11

Discord Username / User ID

resquwue

What does this improvement do?

Two legitimate failure downloads targeting the same --out directory share the same .tmp staging directory. The second writer removes the first writer’s in-progress stage. The first writer can then publish the second writer’s result.json and code.py together with its own meta.json, and return success.

I reproduced this offline using the real built writeBundle: writer A returned snapshot snap_A successfully; disk meta.json said snap_A, while result.json said snap_B and code.py contained code from B. Writer B subsequently failed with ENOENT.

This breaks the bundle identity/completion contract used by coding agents. Parallel triage jobs or two agents sharing an output directory can receive a successful download containing evidence and code from different runs. No malformed API payload or network access is needed.

Details / implementation notes

Deterministic offline reproduction

Build this commit with npm ci and npm run build. Save as repro.mjs at the repository root and run node repro.mjs. The injected fetch never contacts a server.

import { writeBundle } from './dist/lib/bundle.js';
import { mkdtemp, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const dir = await mkdtemp(join(tmpdir(), 'testsprite-bundle-race-'));
function context(id) {
  return {
    snapshotId: id, testId: 'test_' + id, projectId: 'project_demo',
    result: {
      testId: 'test_' + id, status: 'failed', startedAt: null,
      finishedAt: '2026-10-17T10:00:00.000Z',
      videoUrl: 'https://example.test/' + id + '.mp4',
      failureAnalysisUrl: null, snapshotId: id, runIdIfAvailable: 'run_' + id,
      codeVersion: 'v1', targetUrl: 'https://example.test',
      failedStepIndex: null, failureKind: 'assertion', verdict: 'failed',
      executionStatus: 'completed', summary: id,
    },
    steps: [],
    code: { testId: 'test_' + id, language: 'python', framework: 'pytest',
      code: '# code from ' + id, codeVersion: 'v1', etag: null },
    failure: { rootCauseHypothesis: id, evidence: [],
      recommendedFixTarget: { kind: 'unknown', reference: null, rationale: null } },
  };
}
function gate() {
  let entered, release;
  const started = new Promise(r => { entered = r; });
  const response = new Promise(r => { release = r; });
  return { started, release: () => release(new Response('demo-video')),
    fetch: async () => { entered(); return response; } };
}
const a = gate(), b = gate();
const pa = writeBundle(context('A'), { dir, failedOnly: false, fetchImpl: a.fetch });
await a.started;
const pb = writeBundle(context('B'), { dir, failedOnly: false, fetchImpl: b.fetch });
await b.started;
a.release();
const returned = await pa;
const meta = JSON.parse(await readFile(join(dir, 'meta.json'), 'utf8'));
const result = JSON.parse(await readFile(join(dir, 'result.json'), 'utf8'));
console.log({ returned: returned.meta.snapshotId,
  diskMeta: meta.snapshotId, diskResult: result.snapshotId,
  diskCode: await readFile(join(dir, 'code.py'), 'utf8') });
b.release();
console.log(await pb.then(() => 'B succeeded', e => e.message));

Observed: A succeeds with returned=A, diskMeta=A, diskResult=B, diskCode="# code from B". B then fails with ENOENT renaming .tmp/result.json. Each input context independently passes assertContextIntegrity.

Root cause and proposed scope

src/lib/bundle.ts: writeBundle uses freshTmpDir (lines 485 and 572–577), which unconditionally removes a shared

/.tmp. Integrity is checked on the in-memory context before staging; it cannot detect files replaced by the other writer. The unique aside directory in commitBundle does not isolate staging or serialize commits.

Propose coordinating writers for the same resolved output directory, including separate CLI processes, and giving each writer its own owned staging area. An overlapping writer should either wait or fail clearly before touching another writer’s stage. Preserve the existing rollback and foreign-file guarantees. No new runtime dependency should be needed.

Regression coverage proposed

  • Deterministically overlap two writers at the injected artifact fetch: successful output must contain one consistent snapshot, code and evidence.
  • Cross-process overlap on the same output; non-overlapping output directories still work independently.
  • A failed/interrupted writer cannot remove another writer’s stage, committed bundle or lock.
  • Preserve existing rollback/foreign-file cases; run lint, format, typecheck, full tests, coverage >=80%, build and local e2e before a PR.

Duplicate check

Reviewed all 497 currently listed issue/PR records (open and closed) through #500. #487 concerns ownership and deletion of pre-existing user files; #440 concerns serial artifact-download performance; #97 and #196/#246 implement single-writer re-commit rollback. This report is the distinct concurrent-writer mixed-snapshot outcome, including success from A. The staging changes may overlap #487, so maintainer coordination before implementation would be useful.

No production code changes have been made. Implementation will wait for maintainer triage and assignment.

Season 4 CLI Improvement Bonus

  • Entering the Season 4 CLI Improvement Bonus; Discord: resquwue.

Confirmations

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

Activity

  1. zeshi-du commented on Oct 20, 2026

    @zeshi-du
    Contributor

    Thanks @lxcario. Confirmed on v0.15.0: two failure get runs writing into the same --out directory share one staging directory and can end up with each other's files.

    We'll fix this ourselves as part of an internal rework of how the CLI downloads and writes files, together with #425, #430, #487 and #513, since they touch the same code, so we won't take an external PR for it. Tracked internally as DEV-2179. This issue stays open until the fix ships.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    hackathonCLI hackathon submissions

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions