Skip to content

Commit 94cddc3

Browse files
fix(bundle): preserve prior bundle on failed re-commit swap
Replace in-place commitBundle file renames with a directory-level atomic swap. The complete staging tree is moved aside, the prior bundle is only deleted after a successful rename, and failed swaps roll back to the previous meta.json and steps/ contents. Adds commitBundle regression test in bundle.commit.test.ts.
1 parent 1941e30 commit 94cddc3

2 files changed

Lines changed: 109 additions & 84 deletions

File tree

src/lib/bundle.commit.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type * as NodeFsPromises from 'node:fs/promises';
2+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6+
7+
const renameMock = vi.hoisted(() => vi.fn());
8+
9+
vi.mock('node:fs/promises', async importOriginal => {
10+
const actual = (await importOriginal()) as typeof NodeFsPromises;
11+
return {
12+
...actual,
13+
rename: renameMock,
14+
};
15+
});
16+
17+
const { commitBundle } = await import('./bundle.js');
18+
19+
describe('commitBundle', () => {
20+
let realRename: NodeFsPromises.rename;
21+
22+
beforeEach(async () => {
23+
const actual = (await vi.importActual('node:fs/promises')) as typeof NodeFsPromises;
24+
realRename = actual.rename;
25+
renameMock.mockImplementation(realRename);
26+
});
27+
28+
afterEach(() => {
29+
renameMock.mockReset();
30+
});
31+
32+
it('rolls back to the prior complete bundle when the staging swap fails', async () => {
33+
const parent = mkdtempSync(join(tmpdir(), 'bundle-commit-parent-'));
34+
const dir = join(parent, 'bundle');
35+
const tmpDir = join(dir, '.tmp');
36+
37+
mkdirSync(join(dir, 'steps'), { recursive: true });
38+
writeFileSync(join(dir, 'meta.json'), '{"snapshotId":"snap_old"}\n', 'utf8');
39+
writeFileSync(join(dir, 'steps', '01-evidence.json'), '{"step":1}\n', 'utf8');
40+
41+
mkdirSync(join(tmpDir, 'steps'), { recursive: true });
42+
writeFileSync(join(tmpDir, 'meta.json'), '{"snapshotId":"snap_new"}\n', 'utf8');
43+
writeFileSync(join(tmpDir, 'result.json'), '{}\n', 'utf8');
44+
writeFileSync(join(tmpDir, 'steps', '01-evidence.json'), '{"step":9}\n', 'utf8');
45+
46+
renameMock.mockImplementation(async (oldPath, newPath) => {
47+
const src = String(oldPath);
48+
const dest = String(newPath);
49+
if (src.includes('.staging.') && !dest.includes('.prior.')) {
50+
throw Object.assign(new Error('simulated atomic swap failure'), { code: 'EACCES' });
51+
}
52+
return realRename(oldPath, newPath);
53+
});
54+
55+
await expect(commitBundle(tmpDir, dir)).rejects.toThrow('simulated atomic swap failure');
56+
57+
expect(readFileSync(join(dir, 'meta.json'), 'utf8')).toBe('{"snapshotId":"snap_old"}\n');
58+
expect(readFileSync(join(dir, 'steps', '01-evidence.json'), 'utf8')).toBe('{"step":1}\n');
59+
const leftovers = readdirSync(parent).filter(
60+
name => name.includes('.staging.') || name.includes('.prior.'),
61+
);
62+
expect(leftovers).toEqual([]);
63+
});
64+
});

src/lib/bundle.ts

Lines changed: 45 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
* `snapshotId`. We refuse a forged response where
1212
* `bundle.snapshotId !== result.snapshotId` or steps disagree on
1313
* `runIdIfAvailable` (`assertContextIntegrity`).
14-
* 2. **Atomic on disk** — we write to `<dir>/.tmp/...` first and
15-
* `rename()` each file into place. `meta.json` is renamed last so
16-
* the presence of `<dir>/meta.json` ⇔ "bundle is complete and
14+
* 2. **Atomic on disk** — we write to `<dir>/.tmp/...` first, then
15+
* swap the complete staging tree into place via directory rename.
16+
* The prior bundle is moved aside and only deleted after the swap
17+
* succeeds, so a failed re-commit never destroys a complete bundle.
18+
* `meta.json` in the final tree ⇔ "bundle is complete and
1719
* self-consistent".
1820
* 3. **`.partial` on failure** — any download or fs failure leaves
1921
* a `<dir>/.partial` marker (with `requestId`, error summary,
@@ -33,12 +35,13 @@
3335
* when the agent re-runs. M3 may add resume.
3436
*/
3537

36-
import { mkdir, mkdtemp, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
38+
import { randomUUID } from 'node:crypto';
39+
import { mkdir, mkdtemp, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
3740
import type { Writable } from 'node:stream';
3841
import { pipeline } from 'node:stream/promises';
3942
import type { ReadableStream as NodeReadableStream } from 'node:stream/web';
4043
import { createWriteStream } from 'node:fs';
41-
import { dirname, isAbsolute, join, resolve } from 'node:path';
44+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
4245
import type { CliFailureContext, CliTestStep } from '../commands/test.js';
4346
import { ApiError, TransportError } from './errors.js';
4447
import type { FetchImpl } from './http.js';
@@ -465,10 +468,10 @@ export async function writeBundle(
465468
await writeFile(join(tmpDir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8');
466469
filesWritten.push('meta.json');
467470

468-
// Atomic rename: move every file from <dir>/.tmp/* into <dir>/*.
469-
// Steps subdir gets renamed wholesale; meta.json renames last so
470-
// its visibility implies "bundle is complete and atomic on disk."
471-
await commitBundle(tmpDir, dir, filesWritten);
471+
// Atomic directory swap: the complete bundle in <dir>/.tmp/ replaces
472+
// <dir> only after the staging tree is fully built. A failed swap
473+
// rolls back to the prior bundle instead of leaving meta.json gone.
474+
await commitBundle(tmpDir, dir);
472475

473476
return { meta, dir, files: filesWritten };
474477
} catch (err) {
@@ -492,83 +495,43 @@ async function freshTmpDir(dir: string): Promise<string> {
492495
}
493496

494497
/**
495-
* Rename `<tmp>/<file>` → `<dir>/<file>` for every file in `files`.
498+
* Atomically replace `<dir>` with the complete bundle staged in `tmpDir`.
496499
*
497-
* Critical ordering for atomicity (the §3 "agent-safe" contract):
498-
*
499-
* 1. **Remove the OLD `meta.json` first.** The bundle's completion
500-
* signal is `meta.json`'s presence; an agent reading `<dir>` while
501-
* we're mutating it must see "no meta → bundle absent or
502-
* mid-write" rather than "meta points at a snapshot that's already
503-
* been partially overwritten." Removing the old meta is what
504-
* makes the rest of the swap safe to do in place.
505-
* 2. Wipe stale top-level files (e.g. an old `video.mp4` when the new
506-
* bundle has no video). Without this, a fresh bundle could ship
507-
* with a stale video lingering at the top level.
508-
* 3. Replace `<dir>/steps/` wholesale.
509-
* 4. Rename top-level files into place.
510-
* 5. **Rename `meta.json` LAST.** Its visible presence is the atomic
511-
* completion signal; until step 5 lands, agents see "incomplete."
512-
*
513-
* The window between (1) and (5) is bounded by a handful of `rename`
514-
* syscalls — small enough that a SIGKILL there is rare, and any agent
515-
* caught reading the dir during it sees no meta and refuses to consume
516-
* (per §7.3). That's what we want.
500+
* `tmpDir` already contains the full §7 layout (including `meta.json`).
501+
* We move it to a sibling staging directory, swap it into place via
502+
* directory rename, and only delete the prior bundle after the swap
503+
* succeeds. If the swap fails, the prior bundle is restored so a
504+
* re-commit never leaves `meta.json` deleted with no replacement.
517505
*/
518-
async function commitBundle(
519-
tmpDir: string,
520-
dir: string,
521-
files: ReadonlyArray<string>,
522-
): Promise<void> {
523-
// (1) Remove the prior bundle's completion signal FIRST.
524-
await unlink(join(dir, 'meta.json')).catch(() => undefined);
525-
526-
// (2) Sweep stale top-level files that the new bundle won't write.
527-
// If the prior run wrote `video.mp4` and the new run has no video,
528-
// an in-place rename leaves the old video lingering. Enumerate
529-
// current top-level entries and remove anything that isn't being
530-
// freshly renamed in.
531-
const topLevel = files.filter(f => !f.startsWith('steps/'));
532-
const newTopLevelSet = new Set(topLevel);
533-
newTopLevelSet.add('meta.json'); // about to land last, do not delete
534-
const existing = await readdir(dir).catch(() => [] as string[]);
535-
for (const entry of existing) {
536-
// Preserve the writer's own scratch dir + the .partial marker
537-
// (we'll re-evaluate .partial at the end of commit). Anything else
538-
// not-listed in the new bundle is stale.
539-
if (entry === '.tmp' || entry === '.partial') continue;
540-
if (newTopLevelSet.has(entry)) continue;
541-
if (entry === 'steps') continue; // handled below
542-
await rm(join(dir, entry), { recursive: true, force: true });
543-
}
544-
545-
// (3) Replace `<dir>/steps/` with `<tmp>/steps/`.
546-
const stepsTmp = join(tmpDir, 'steps');
547-
const stepsDir = join(dir, 'steps');
548-
await rm(stepsDir, { recursive: true, force: true });
549-
if (await dirExists(stepsTmp)) {
550-
await rename(stepsTmp, stepsDir);
551-
}
506+
export async function commitBundle(tmpDir: string, dir: string): Promise<void> {
507+
const parent = dirname(dir);
508+
const base = basename(dir);
509+
const stagingDir = join(parent, `.${base}.staging.${randomUUID()}`);
510+
const priorDir = join(parent, `.${base}.prior.${randomUUID()}`);
552511

553-
// (4) Top-level files (result/failure/code/video). meta.json renames
554-
// LAST; track it separately.
555-
const metaIdx = topLevel.indexOf('meta.json');
556-
const beforeMeta = metaIdx >= 0 ? topLevel.filter((_, i) => i !== metaIdx) : topLevel;
557-
for (const file of beforeMeta) {
558-
await rename(join(tmpDir, file), join(dir, file));
559-
}
512+
// Move the complete new bundle out of <dir>/.tmp/ without mutating
513+
// the existing bundle tree yet.
514+
await rename(tmpDir, stagingDir);
560515

561-
// (5) meta.json LAST → atomic completion signal.
562-
if (metaIdx >= 0) {
563-
await rename(join(tmpDir, 'meta.json'), join(dir, 'meta.json'));
516+
try {
517+
if (await dirExists(dir)) {
518+
await rename(dir, priorDir);
519+
}
520+
await rename(stagingDir, dir);
521+
await rm(priorDir, { recursive: true, force: true });
522+
await unlink(join(dir, '.partial')).catch(() => undefined);
523+
} catch (err) {
524+
const priorPresent = await dirExists(priorDir);
525+
const dirPresent = await dirExists(dir);
526+
if (priorPresent && !dirPresent) {
527+
await rename(priorDir, dir);
528+
} else if (priorPresent && dirPresent) {
529+
await rm(dir, { recursive: true, force: true });
530+
await rename(priorDir, dir);
531+
}
532+
await rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
533+
throw err;
564534
}
565-
566-
// .partial from a prior aborted run is now stale. Remove it so an
567-
// agent inspecting the dir sees only the fresh bundle.
568-
await unlink(join(dir, '.partial')).catch(() => undefined);
569-
570-
// Clean up the now-empty tmp dir.
571-
await rm(tmpDir, { recursive: true, force: true });
572535
}
573536

574537
async function dirExists(path: string): Promise<boolean> {
@@ -796,7 +759,5 @@ function bundleIntegrityError(
796759
});
797760
}
798761

799-
// Avoid unused-import lint warning for the `mkdtemp` and `readdir`
800-
// helpers reserved for forensics + future resume work.
762+
// Avoid unused-import lint warning for `mkdtemp` (reserved for resume work).
801763
void mkdtemp;
802-
void readdir;

0 commit comments

Comments
 (0)