* Rename ` / ` → ` / ` for every file in `files`. * * Critical ordering for atomicity (the §3 "agent-safe" contract): * * 1. **Remove the OLD `meta.json` first.** The bundle's completion * signal is `meta.json`'s presence; an agent reading ` ` while * we're mu
( tmpDir: string, dir: string, files: ReadonlyArray<string>, )
| 516 | * (per §7.3). That's what we want. |
| 517 | */ |
| 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 | } |
| 552 | |
| 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 | } |
| 560 | |
| 561 | // (5) meta.json LAST → atomic completion signal. |
| 562 | if (metaIdx >= 0) { |
| 563 | await rename(join(tmpDir, 'meta.json'), join(dir, 'meta.json')); |
| 564 | } |
| 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 }); |
| 572 | } |
| 573 | |
| 574 | async function dirExists(path: string): Promise<boolean> { |
| 575 | try { |
no test coverage detected