diff --git a/src/resources/extensions/gsd/files.ts b/src/resources/extensions/gsd/files.ts index 0edbd87e8..5a282b8c3 100644 --- a/src/resources/extensions/gsd/files.ts +++ b/src/resources/extensions/gsd/files.ts @@ -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 { 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 {