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,
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' ;
3740import type { Writable } from 'node:stream' ;
3841import { pipeline } from 'node:stream/promises' ;
3942import type { ReadableStream as NodeReadableStream } from 'node:stream/web' ;
4043import { createWriteStream } from 'node:fs' ;
41- import { dirname , isAbsolute , join , resolve } from 'node:path' ;
44+ import { basename , dirname , isAbsolute , join , resolve } from 'node:path' ;
4245import type { CliFailureContext , CliTestStep } from '../commands/test.js' ;
4346import { ApiError , TransportError } from './errors.js' ;
4447import 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
574537async 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).
801763void mkdtemp ;
802- void readdir ;
0 commit comments