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' ;
@@ -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 / ^ c o d e \. [ A - Z a - z 0 - 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
628639async function dirExists ( path : string ) : Promise < boolean > {
0 commit comments