Skip to content

Commit 6264d78

Browse files
fix(bundle): atomic re-commit via per-entry aside with rollback
1 parent b9e9601 commit 6264d78

2 files changed

Lines changed: 207 additions & 72 deletions

File tree

src/lib/bundle.commit.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import type * as NodeFsPromises from 'node:fs/promises';
2+
import {
3+
existsSync,
4+
mkdirSync,
5+
mkdtempSync,
6+
readdirSync,
7+
readFileSync,
8+
writeFileSync,
9+
} from 'node:fs';
10+
import { tmpdir } from 'node:os';
11+
import { join } from 'node:path';
12+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13+
14+
const renameMock = vi.hoisted(() => vi.fn());
15+
const rmMock = vi.hoisted(() => vi.fn());
16+
17+
vi.mock('node:fs/promises', async importOriginal => {
18+
const actual = (await importOriginal()) as typeof NodeFsPromises;
19+
return {
20+
...actual,
21+
rename: renameMock,
22+
rm: rmMock,
23+
};
24+
});
25+
26+
const { commitBundle } = await import('./bundle.js');
27+
28+
describe('commitBundle', () => {
29+
let realRename: typeof NodeFsPromises.rename;
30+
let realRm: typeof NodeFsPromises.rm;
31+
32+
beforeEach(async () => {
33+
const actual = (await vi.importActual('node:fs/promises')) as typeof NodeFsPromises;
34+
realRename = actual.rename;
35+
realRm = actual.rm;
36+
renameMock.mockImplementation(realRename);
37+
rmMock.mockImplementation(realRm);
38+
});
39+
40+
afterEach(() => {
41+
renameMock.mockReset();
42+
rmMock.mockReset();
43+
});
44+
45+
async function withTempParent(run: (parent: string) => Promise<void>): Promise<void> {
46+
const parent = mkdtempSync(join(tmpdir(), 'bundle-commit-parent-'));
47+
try {
48+
await run(parent);
49+
} finally {
50+
await realRm(parent, { recursive: true, force: true }).catch(() => undefined);
51+
}
52+
}
53+
54+
function seedBundleDirs(parent: string): { dir: string; tmpDir: string; files: string[] } {
55+
const dir = join(parent, 'bundle');
56+
const tmpDir = join(dir, '.tmp');
57+
58+
mkdirSync(dir, { recursive: true });
59+
writeFileSync(join(dir, 'notes.txt'), 'foreign notes\n', 'utf8');
60+
mkdirSync(join(dir, 'steps'), { recursive: true });
61+
writeFileSync(join(dir, 'meta.json'), '{"snapshotId":"snap_old"}\n', 'utf8');
62+
writeFileSync(join(dir, 'steps', '01-evidence.json'), '{"step":1}\n', 'utf8');
63+
64+
mkdirSync(join(tmpDir, 'steps'), { recursive: true });
65+
writeFileSync(join(tmpDir, 'meta.json'), '{"snapshotId":"snap_new"}\n', 'utf8');
66+
writeFileSync(join(tmpDir, 'result.json'), '{}\n', 'utf8');
67+
writeFileSync(join(tmpDir, 'steps', '01-evidence.json'), '{"step":9}\n', 'utf8');
68+
69+
return { dir, tmpDir, files: ['result.json', 'meta.json', 'steps/01-evidence.json'] };
70+
}
71+
72+
it('rolls back to the prior complete bundle when a staged rename fails', async () => {
73+
await withTempParent(async parent => {
74+
const { dir, tmpDir, files } = seedBundleDirs(parent);
75+
76+
renameMock.mockImplementation(async (oldPath, newPath) => {
77+
const dest = String(newPath);
78+
if (dest.endsWith('result.json') && !dest.includes('.aside.')) {
79+
throw Object.assign(new Error('simulated install failure'), { code: 'EACCES' });
80+
}
81+
return realRename(oldPath, newPath);
82+
});
83+
84+
await expect(commitBundle(tmpDir, dir, files)).rejects.toThrow('simulated install failure');
85+
86+
expect(readFileSync(join(dir, 'meta.json'), 'utf8')).toBe('{"snapshotId":"snap_old"}\n');
87+
expect(readFileSync(join(dir, 'steps', '01-evidence.json'), 'utf8')).toBe('{"step":1}\n');
88+
expect(readFileSync(join(dir, 'notes.txt'), 'utf8')).toBe('foreign notes\n');
89+
const leftovers = readdirSync(parent).filter(name => name.includes('.aside.'));
90+
expect(leftovers).toEqual([]);
91+
});
92+
});
93+
94+
it('preserves foreign files while installing the new bundle on success', async () => {
95+
await withTempParent(async parent => {
96+
const { dir, tmpDir, files } = seedBundleDirs(parent);
97+
98+
await expect(commitBundle(tmpDir, dir, files)).resolves.toBeUndefined();
99+
100+
expect(readFileSync(join(dir, 'meta.json'), 'utf8')).toBe('{"snapshotId":"snap_new"}\n');
101+
expect(readFileSync(join(dir, 'steps', '01-evidence.json'), 'utf8')).toBe('{"step":9}\n');
102+
expect(readFileSync(join(dir, 'notes.txt'), 'utf8')).toBe('foreign notes\n');
103+
expect(existsSync(join(dir, 'result.json'))).toBe(true);
104+
});
105+
});
106+
107+
it('keeps the new bundle when post-commit aside cleanup fails', async () => {
108+
await withTempParent(async parent => {
109+
const { dir, tmpDir, files } = seedBundleDirs(parent);
110+
111+
rmMock.mockImplementation(async (path, options) => {
112+
if (String(path).includes('.aside.')) {
113+
throw Object.assign(new Error('simulated aside cleanup failure'), { code: 'EACCES' });
114+
}
115+
return realRm(path, options);
116+
});
117+
118+
await expect(commitBundle(tmpDir, dir, files)).resolves.toBeUndefined();
119+
120+
expect(readFileSync(join(dir, 'meta.json'), 'utf8')).toBe('{"snapshotId":"snap_new"}\n');
121+
expect(readFileSync(join(dir, 'notes.txt'), 'utf8')).toBe('foreign notes\n');
122+
});
123+
});
124+
});

src/lib/bundle.ts

Lines changed: 83 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,13 @@
3333
* when the agent re-runs. M3 may add resume.
3434
*/
3535

36+
import { randomUUID } from 'node:crypto';
3637
import { mkdir, mkdtemp, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
3738
import type { Writable } from 'node:stream';
3839
import { pipeline } from 'node:stream/promises';
3940
import type { ReadableStream as NodeReadableStream } from 'node:stream/web';
4041
import { createWriteStream } from 'node:fs';
41-
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
42+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4243
import type { CliFailureContext, CliTestStep } from '../commands/test.js';
4344
import { ApiError, TransportError, localValidationError } from './errors.js';
4445
import { requireEnum } from './validate.js';
@@ -519,30 +520,6 @@ async function freshTmpDir(dir: string): Promise<string> {
519520
return tmpDir;
520521
}
521522

522-
/**
523-
* Rename `<tmp>/<file>` → `<dir>/<file>` for every file in `files`.
524-
*
525-
* Critical ordering for atomicity (the §3 "agent-safe" contract):
526-
*
527-
* 1. **Remove the OLD `meta.json` first.** The bundle's completion
528-
* signal is `meta.json`'s presence; an agent reading `<dir>` while
529-
* we're mutating it must see "no meta → bundle absent or
530-
* mid-write" rather than "meta points at a snapshot that's already
531-
* been partially overwritten." Removing the old meta is what
532-
* makes the rest of the swap safe to do in place.
533-
* 2. Wipe stale top-level files (e.g. an old `video.mp4` when the new
534-
* bundle has no video). Without this, a fresh bundle could ship
535-
* with a stale video lingering at the top level.
536-
* 3. Replace `<dir>/steps/` wholesale.
537-
* 4. Rename top-level files into place.
538-
* 5. **Rename `meta.json` LAST.** Its visible presence is the atomic
539-
* completion signal; until step 5 lands, agents see "incomplete."
540-
*
541-
* The window between (1) and (5) is bounded by a handful of `rename`
542-
* syscalls — small enough that a SIGKILL there is rare, and any agent
543-
* caught reading the dir during it sees no meta and refuses to consume
544-
* (per §7.3). That's what we want.
545-
*/
546523
/**
547524
* Whether a top-level directory entry belongs to the bundle format —
548525
* i.e. something a prior `writeBundle` could have produced and this
@@ -567,62 +544,96 @@ export function isBundleOwnedEntry(entry: string): boolean {
567544
return /^code\.[A-Za-z0-9]+$/.test(entry);
568545
}
569546

570-
async function commitBundle(
547+
/**
548+
* Atomically install the complete bundle staged in `tmpDir` into `dir`.
549+
*
550+
* Compatible with the #162 data-loss guard: only `isBundleOwnedEntry`
551+
* names are moved aside or replaced — foreign files in `--out` survive.
552+
*
553+
* Re-commit safety: bundle-owned entries are renamed to a sibling
554+
* aside directory before the new artifacts land. `meta.json` is installed
555+
* last so its presence remains the completion signal. On failure, aside
556+
* entries are restored so a failed re-commit never deletes `meta.json`
557+
* without leaving a usable prior bundle (or the prior bundle restored).
558+
*/
559+
export async function commitBundle(
571560
tmpDir: string,
572561
dir: string,
573562
files: ReadonlyArray<string>,
574563
): Promise<void> {
575-
// (1) Remove the prior bundle's completion signal FIRST.
576-
await unlink(join(dir, 'meta.json')).catch(() => undefined);
577-
578-
// (2) Sweep stale top-level files that the new bundle won't write.
579-
// If the prior run wrote `video.mp4` and the new run has no video,
580-
// an in-place rename leaves the old video lingering. Only entries the
581-
// bundle format OWNS are candidates: `--out` may point at a directory
582-
// that also holds the user's unrelated files, and those must survive
583-
// the commit (deleting them would be silent data loss).
584-
const topLevel = files.filter(f => !f.startsWith('steps/'));
585-
const newTopLevelSet = new Set(topLevel);
586-
newTopLevelSet.add('meta.json'); // about to land last, do not delete
587-
const existing = await readdir(dir).catch(() => [] as string[]);
588-
for (const entry of existing) {
589-
// Preserve the writer's own scratch dir + the .partial marker
590-
// (we'll re-evaluate .partial at the end of commit). Any other
591-
// bundle-owned entry not-listed in the new bundle is stale.
592-
if (entry === '.tmp' || entry === '.partial') continue;
593-
if (newTopLevelSet.has(entry)) continue;
594-
if (entry === 'steps') continue; // handled below
595-
if (!isBundleOwnedEntry(entry)) continue; // foreign file — never touch
596-
await rm(join(dir, entry), { recursive: true, force: true });
597-
}
564+
const parent = dirname(dir);
565+
const base = basename(dir);
566+
const asideDir = join(parent, `.${base}.aside.${randomUUID()}`);
567+
const asideLog: Array<{ asidePath: string; restorePath: string }> = [];
568+
569+
const asideIfPresent = async (entry: string): Promise<void> => {
570+
const restorePath = join(dir, entry);
571+
if (!(await pathExists(restorePath))) return;
572+
const asidePath = join(asideDir, entry);
573+
await mkdir(dirname(asidePath), { recursive: true });
574+
await rename(restorePath, asidePath);
575+
asideLog.push({ asidePath, restorePath });
576+
};
598577

599-
// (3) Replace `<dir>/steps/` with `<tmp>/steps/`.
600-
const stepsTmp = join(tmpDir, 'steps');
601-
const stepsDir = join(dir, 'steps');
602-
await rm(stepsDir, { recursive: true, force: true });
603-
if (await dirExists(stepsTmp)) {
604-
await rename(stepsTmp, stepsDir);
605-
}
578+
const rollback = async (): Promise<void> => {
579+
for (const { asidePath, restorePath } of [...asideLog].reverse()) {
580+
await rm(restorePath, { recursive: true, force: true }).catch(() => undefined);
581+
await rename(asidePath, restorePath).catch(() => undefined);
582+
}
583+
await rm(asideDir, { recursive: true, force: true }).catch(() => undefined);
584+
};
606585

607-
// (4) Top-level files (result/failure/code/video). meta.json renames
608-
// LAST; track it separately.
609-
const metaIdx = topLevel.indexOf('meta.json');
610-
const beforeMeta = metaIdx >= 0 ? topLevel.filter((_, i) => i !== metaIdx) : topLevel;
611-
for (const file of beforeMeta) {
612-
await rename(join(tmpDir, file), join(dir, file));
613-
}
586+
try {
587+
const topLevel = files.filter(f => !f.startsWith('steps/'));
588+
const newTopLevelSet = new Set(topLevel);
589+
newTopLevelSet.add('meta.json');
590+
591+
const existing = await readdir(dir).catch(() => [] as string[]);
592+
for (const entry of existing) {
593+
if (entry === '.tmp') continue;
594+
if (!isBundleOwnedEntry(entry)) continue;
595+
596+
const isStale = entry !== 'steps' && entry !== '.partial' && !newTopLevelSet.has(entry);
597+
const willReplace = entry === 'steps' || newTopLevelSet.has(entry);
598+
if (isStale || willReplace) {
599+
await asideIfPresent(entry);
600+
}
601+
}
614602

615-
// (5) meta.json LAST → atomic completion signal.
616-
if (metaIdx >= 0) {
617-
await rename(join(tmpDir, 'meta.json'), join(dir, 'meta.json'));
618-
}
603+
const stepsTmp = join(tmpDir, 'steps');
604+
const stepsDir = join(dir, 'steps');
605+
if (await dirExists(stepsTmp)) {
606+
await rename(stepsTmp, stepsDir);
607+
}
619608

620-
// .partial from a prior aborted run is now stale. Remove it so an
621-
// agent inspecting the dir sees only the fresh bundle.
622-
await unlink(join(dir, '.partial')).catch(() => undefined);
609+
const metaIdx = topLevel.indexOf('meta.json');
610+
const beforeMeta = metaIdx >= 0 ? topLevel.filter((_, i) => i !== metaIdx) : topLevel;
611+
for (const file of beforeMeta) {
612+
await rename(join(tmpDir, file), join(dir, file));
613+
}
623614

624-
// Clean up the now-empty tmp dir.
625-
await rm(tmpDir, { recursive: true, force: true });
615+
if (metaIdx >= 0) {
616+
await rename(join(tmpDir, 'meta.json'), join(dir, 'meta.json'));
617+
}
618+
619+
await unlink(join(dir, '.partial')).catch(() => undefined);
620+
await rm(tmpDir, { recursive: true, force: true });
621+
// Best-effort aside cleanup — failures must not roll back a committed bundle.
622+
await rm(asideDir, { recursive: true, force: true }).catch(() => undefined);
623+
} catch (err) {
624+
await rollback();
625+
await rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
626+
throw err;
627+
}
628+
}
629+
630+
async function pathExists(path: string): Promise<boolean> {
631+
try {
632+
await stat(path);
633+
return true;
634+
} catch {
635+
return false;
636+
}
626637
}
627638

628639
async function dirExists(path: string): Promise<boolean> {

0 commit comments

Comments
 (0)