3333 * when the agent re-runs. M3 may add resume.
3434 */
3535
36+ import { randomUUID } from 'node:crypto' ;
3637import { mkdir , mkdtemp , readdir , rename , rm , stat , unlink , writeFile } from 'node:fs/promises' ;
3738import type { Writable } from 'node:stream' ;
3839import { pipeline } from 'node:stream/promises' ;
3940import type { ReadableStream as NodeReadableStream } from 'node:stream/web' ;
4041import { 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' ;
4243import type { CliFailureContext , CliTestStep } from '../commands/test.js' ;
4344import { ApiError , TransportError , localValidationError } from './errors.js' ;
4445import { requireEnum } from './validate.js' ;
@@ -536,30 +537,6 @@ async function freshTmpDir(dir: string): Promise<string> {
536537 return tmpDir ;
537538}
538539
539- /**
540- * Rename `<tmp>/<file>` → `<dir>/<file>` for every file in `files`.
541- *
542- * Critical ordering for atomicity (the §3 "agent-safe" contract):
543- *
544- * 1. **Remove the OLD `meta.json` first.** The bundle's completion
545- * signal is `meta.json`'s presence; an agent reading `<dir>` while
546- * we're mutating it must see "no meta → bundle absent or
547- * mid-write" rather than "meta points at a snapshot that's already
548- * been partially overwritten." Removing the old meta is what
549- * makes the rest of the swap safe to do in place.
550- * 2. Wipe stale top-level files (e.g. an old `video.mp4` when the new
551- * bundle has no video). Without this, a fresh bundle could ship
552- * with a stale video lingering at the top level.
553- * 3. Replace `<dir>/steps/` wholesale.
554- * 4. Rename top-level files into place.
555- * 5. **Rename `meta.json` LAST.** Its visible presence is the atomic
556- * completion signal; until step 5 lands, agents see "incomplete."
557- *
558- * The window between (1) and (5) is bounded by a handful of `rename`
559- * syscalls — small enough that a SIGKILL there is rare, and any agent
560- * caught reading the dir during it sees no meta and refuses to consume
561- * (per §7.3). That's what we want.
562- */
563540/**
564541 * Whether a top-level directory entry belongs to the bundle format —
565542 * i.e. something a prior `writeBundle` could have produced and this
@@ -584,62 +561,101 @@ export function isBundleOwnedEntry(entry: string): boolean {
584561 return / ^ c o d e \. [ A - Z a - z 0 - 9 ] + $ / . test ( entry ) ;
585562}
586563
587- async function commitBundle (
564+ /**
565+ * Atomically install the complete bundle staged in `tmpDir` into `dir`.
566+ *
567+ * Compatible with the #162 data-loss guard: only `isBundleOwnedEntry`
568+ * names are moved aside or replaced — foreign files in `--out` survive.
569+ *
570+ * Re-commit safety: bundle-owned entries are renamed to a sibling
571+ * aside directory before the new artifacts land. The prior `meta.json`
572+ * is moved aside first (§3/§7.3 — no meta ⇒ refuse to consume) so a
573+ * concurrent reader never sees meta pointing at steps already gone.
574+ * The new `meta.json` is installed last. On failure, aside entries are
575+ * restored so a failed re-commit never leaves the directory unusable.
576+ */
577+ export async function commitBundle (
588578 tmpDir : string ,
589579 dir : string ,
590580 files : ReadonlyArray < string > ,
591581) : Promise < void > {
592- // (1) Remove the prior bundle's completion signal FIRST.
593- await unlink ( join ( dir , 'meta.json' ) ) . catch ( ( ) => undefined ) ;
594-
595- // (2) Sweep stale top-level files that the new bundle won't write.
596- // If the prior run wrote `video.mp4` and the new run has no video,
597- // an in-place rename leaves the old video lingering. Only entries the
598- // bundle format OWNS are candidates: `--out` may point at a directory
599- // that also holds the user's unrelated files, and those must survive
600- // the commit (deleting them would be silent data loss).
601- const topLevel = files . filter ( f => ! f . startsWith ( 'steps/' ) ) ;
602- const newTopLevelSet = new Set ( topLevel ) ;
603- newTopLevelSet . add ( 'meta.json' ) ; // about to land last, do not delete
604- const existing = await readdir ( dir ) . catch ( ( ) => [ ] as string [ ] ) ;
605- for ( const entry of existing ) {
606- // Preserve the writer's own scratch dir + the .partial marker
607- // (we'll re-evaluate .partial at the end of commit). Any other
608- // bundle-owned entry not-listed in the new bundle is stale.
609- if ( entry === '.tmp' || entry === '.partial' ) continue ;
610- if ( newTopLevelSet . has ( entry ) ) continue ;
611- if ( entry === 'steps' ) continue ; // handled below
612- if ( ! isBundleOwnedEntry ( entry ) ) continue ; // foreign file — never touch
613- await rm ( join ( dir , entry ) , { recursive : true , force : true } ) ;
614- }
582+ const parent = dirname ( dir ) ;
583+ const base = basename ( dir ) ;
584+ const asideDir = join ( parent , `.${ base } .aside.${ randomUUID ( ) } ` ) ;
585+ const asideLog : Array < { asidePath : string ; restorePath : string } > = [ ] ;
586+
587+ const asideIfPresent = async ( entry : string ) : Promise < void > => {
588+ const restorePath = join ( dir , entry ) ;
589+ if ( ! ( await pathExists ( restorePath ) ) ) return ;
590+ const asidePath = join ( asideDir , entry ) ;
591+ await mkdir ( dirname ( asidePath ) , { recursive : true } ) ;
592+ await rename ( restorePath , asidePath ) ;
593+ asideLog . push ( { asidePath, restorePath } ) ;
594+ } ;
615595
616- // (3) Replace `<dir>/steps/` with `<tmp>/steps/`.
617- const stepsTmp = join ( tmpDir , 'steps' ) ;
618- const stepsDir = join ( dir , 'steps' ) ;
619- await rm ( stepsDir , { recursive : true , force : true } ) ;
620- if ( await dirExists ( stepsTmp ) ) {
621- await rename ( stepsTmp , stepsDir ) ;
622- }
596+ const rollback = async ( ) : Promise < void > => {
597+ for ( const { asidePath , restorePath } of [ ... asideLog ] . reverse ( ) ) {
598+ await rm ( restorePath , { recursive : true , force : true } ) . catch ( ( ) => undefined ) ;
599+ await rename ( asidePath , restorePath ) . catch ( ( ) => undefined ) ;
600+ }
601+ await rm ( asideDir , { recursive : true , force : true } ) . catch ( ( ) => undefined ) ;
602+ } ;
623603
624- // (4) Top-level files (result/failure/code/video). meta.json renames
625- // LAST; track it separately.
626- const metaIdx = topLevel . indexOf ( 'meta.json' ) ;
627- const beforeMeta = metaIdx >= 0 ? topLevel . filter ( ( _ , i ) => i !== metaIdx ) : topLevel ;
628- for ( const file of beforeMeta ) {
629- await rename ( join ( tmpDir , file ) , join ( dir , file ) ) ;
630- }
604+ try {
605+ const topLevel = files . filter ( f => ! f . startsWith ( 'steps/' ) ) ;
606+ const newTopLevelSet = new Set ( topLevel ) ;
607+ newTopLevelSet . add ( 'meta.json' ) ;
608+
609+ // meta.json first — its presence is the completion signal; do not
610+ // move steps (or anything else) aside while a stale meta is still visible.
611+ await asideIfPresent ( 'meta.json' ) ;
612+
613+ const existing = await readdir ( dir ) . catch ( ( ) => [ ] as string [ ] ) ;
614+ for ( const entry of existing ) {
615+ if ( entry === '.tmp' || entry === 'meta.json' ) continue ;
616+ if ( ! isBundleOwnedEntry ( entry ) ) continue ;
617+
618+ const isStale = entry !== 'steps' && entry !== '.partial' && ! newTopLevelSet . has ( entry ) ;
619+ const willReplace = entry === 'steps' || newTopLevelSet . has ( entry ) ;
620+ if ( isStale || willReplace ) {
621+ await asideIfPresent ( entry ) ;
622+ }
623+ }
631624
632- // (5) meta.json LAST → atomic completion signal.
633- if ( metaIdx >= 0 ) {
634- await rename ( join ( tmpDir , 'meta.json' ) , join ( dir , 'meta.json' ) ) ;
635- }
625+ const stepsTmp = join ( tmpDir , 'steps' ) ;
626+ const stepsDir = join ( dir , 'steps' ) ;
627+ if ( await dirExists ( stepsTmp ) ) {
628+ await rename ( stepsTmp , stepsDir ) ;
629+ }
636630
637- // .partial from a prior aborted run is now stale. Remove it so an
638- // agent inspecting the dir sees only the fresh bundle.
639- await unlink ( join ( dir , '.partial' ) ) . catch ( ( ) => undefined ) ;
631+ const metaIdx = topLevel . indexOf ( 'meta.json' ) ;
632+ const beforeMeta = metaIdx >= 0 ? topLevel . filter ( ( _ , i ) => i !== metaIdx ) : topLevel ;
633+ for ( const file of beforeMeta ) {
634+ await rename ( join ( tmpDir , file ) , join ( dir , file ) ) ;
635+ }
640636
641- // Clean up the now-empty tmp dir.
642- await rm ( tmpDir , { recursive : true , force : true } ) ;
637+ if ( metaIdx >= 0 ) {
638+ await rename ( join ( tmpDir , 'meta.json' ) , join ( dir , 'meta.json' ) ) ;
639+ }
640+
641+ await unlink ( join ( dir , '.partial' ) ) . catch ( ( ) => undefined ) ;
642+ await rm ( tmpDir , { recursive : true , force : true } ) ;
643+ // Best-effort aside cleanup — failures must not roll back a committed bundle.
644+ await rm ( asideDir , { recursive : true , force : true } ) . catch ( ( ) => undefined ) ;
645+ } catch ( err ) {
646+ await rollback ( ) ;
647+ await rm ( tmpDir , { recursive : true , force : true } ) . catch ( ( ) => undefined ) ;
648+ throw err ;
649+ }
650+ }
651+
652+ async function pathExists ( path : string ) : Promise < boolean > {
653+ try {
654+ await stat ( path ) ;
655+ return true ;
656+ } catch {
657+ return false ;
658+ }
643659}
644660
645661async function dirExists ( path : string ) : Promise < boolean > {
0 commit comments