fix: use unique temp paths in saveFile() to prevent parallel write collisions (#810) (#828)

When multiple tool calls (e.g. concurrent gsd_save_decision) target the
same markdown file, the deterministic .tmp suffix caused ENOENT on
rename() because one caller consumed the temp file before another could
rename it.

Replace the static `.tmp` suffix with a per-call random suffix so each
concurrent writer gets its own temp file. Also clean up orphaned temp
files on rename failure.
This commit is contained in:
Tom Boucher 2026-03-17 09:48:18 -04:00 committed by GitHub
parent b44896e187
commit ef706726c8

View file

@ -5,6 +5,7 @@
import { promises as fs } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { randomBytes } from 'node:crypto';
import { resolveMilestoneFile, relMilestoneFile, resolveGsdRootFile } from './paths.js';
import { milestoneIdSort, findMilestoneIds } from './guided-flow.js';
@ -705,9 +706,19 @@ export async function saveFile(path: string, content: string): Promise<void> {
const dir = dirname(path);
await fs.mkdir(dir, { recursive: true });
const tmpPath = path + '.tmp';
// Use a unique temp path per call to avoid collisions when parallel
// tool calls target the same file (e.g. concurrent gsd_save_decision).
// rename() is atomic on POSIX, so last-writer-wins is correct for
// regenerate-from-DB writes.
const tmpPath = path + `.tmp.${randomBytes(4).toString("hex")}`;
await fs.writeFile(tmpPath, content, 'utf-8');
await fs.rename(tmpPath, path);
try {
await fs.rename(tmpPath, path);
} catch (err) {
// Clean up orphaned temp file on rename failure
await fs.unlink(tmpPath).catch(() => {});
throw err;
}
}
export function parseRequirementCounts(content: string | null): RequirementCounts {