{"record":{"id":"eeefe7c444844ad5","repo":"Hmbown/CodeWhale","slug":"the-live-pet-recording-was-changed-or-replaced-externally","errorCode":null,"errorMessage":"The live pet recording was changed or replaced externally; existing files were preserved.","messagePattern":"The live pet recording was changed or replaced externally; existing files were preserved\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"pet/scripts/lib/pet-recorder.mjs","lineNumber":106,"sourceCode":"        if (!/^[0-9]{6,}\\.jsonl$/.test(suffix)) continue;\n        const number = Number(suffix.slice(0, -6));\n        if (!Number.isSafeInteger(number) || number >= Number.MAX_SAFE_INTEGER)\n          throw new Error('Pet archive numbering is exhausted; existing files were preserved.');\n        segment = Math.max(segment, number);\n      }\n    }\n    expectedMtime ??= (await output.stat({ bigint: true })).mtimeNs;\n  } catch (error) { try { await output?.close(); } finally { lock.close(); } throw error; }\n  return {\n    async append(bucket) {\n      if (!output || busy) throw new Error('Pet recorder is closed or already writing.');\n      validatePetBucket(bucket);\n      busy = true;\n      try {\n        await lock.check();\n        const held = await output.stat({ bigint: true }), current = await lstat(path, { bigint: true });\n        if (!current.isFile() || held.dev !== current.dev || held.ino !== current.ino || held.size !== BigInt(bytes) || held.mtimeNs !== expectedMtime)\n          throw new Error('The live pet recording was changed or replaced externally; existing files were preserved.');\n        const encode = seq => encodePetJSONL([{ ...bucket, sequence: seq, simTimeMs: seq * PET_BIN_MS }]);\n        let row = encode(sequence), size = Buffer.byteLength(row);\n        if (restart || sequence >= maxBuckets || bytes + size > maxBytes) {\n          row = encode(0); size = Buffer.byteLength(row);\n          if (size > maxBytes) throw new Error('Pet bucket exceeds the recording segment byte limit.');\n          const temporary = resolve(dirname(path), `.${basename(path)}.next-${randomUUID()}`);\n          const archive = `${path}.segment-${String(segment + 1).padStart(6, '0')}.jsonl`;\n          let next, installed = false, created = false;\n          try {\n            next = await open(temporary, 'wx', 0o600); created = true;\n            await next.writeFile(row); await next.sync();\n            const nextIdentity = await next.stat({ bigint: true });\n            await next.close(); next = undefined;\n            await output.sync();\n            // link is exclusive: a collision never replaces someone else's\n            // archive. Persist this name before replacing the live pathname.\n            await link(path, archive); await syncDirectory(dirname(path));\n            // Windows can reject replacement while either writer handle is","sourceCodeStart":88,"sourceCodeEnd":124,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/433685b2024e7bc4c99e1e2e326bcad39b4d9d65/pet/scripts/lib/pet-recorder.mjs#L88-L124","documentation":"append() verifies the recording file it holds is still the exact same file on disk (device, inode, size, mtime) and is still a regular file at the expected path before writing. Any mismatch — the file was truncated, appended to, deleted and recreated, or symlinked elsewhere — aborts the append and preserves whatever is on disk.","triggerScenarios":"Another process wrote to or truncated the recording between appends; the recording file was deleted and recreated with the same name; the path now points at a different file (symlink swap, rename over); an editor rewrote the file during recording.","commonSituations":"A second writer (script, log shipper, editor with atomic-save) modifying the live recording; deployment or cleanup jobs deleting/recreating the file; a test harness resetting the file mid-session.","solutions":["Stop all other writers to the recording path; only the recorder should touch it.","Restore the original file (undo the truncation/rewrite) or restart recording from a fresh path.","If a symlink/rotation scheme replaces the file, point the recorder at a dedicated directory nothing else manages.","Re-run and, if the external writer is legitimate, coordinate through the recorder instead of writing directly."],"exampleFix":"// before: external job truncates the file\ntruncate -s 0 pet-recording.jsonl\n// after: let only the recorder manage it\n// remove the truncate job; to start fresh, close() then delete + recreate via the recorder","handlingStrategy":"try-catch","validationCode":"const held = await fs.stat(path, { bigint: true });\nconst current = await fs.lstat(path, { bigint: true });\nif (!current.isFile() || held.ino !== current.ino) throw new Error('recording path was replaced; restart recorder');","typeGuard":null,"tryCatchPattern":"try {\n  await recorder.append(bucket);\n} catch (e) {\n  if (e.message.includes('changed or replaced externally')) {\n    // disk state won: stop writing, inspect the file, restart if safe\n    await recorder.close().catch(() => {});\n  } else throw e;\n}","preventionTips":["Give the recorder exclusive ownership of the recording path","Disable editor atomic-save for the recording directory","Never truncate/rewrite the live file from jobs while recording","If the file must be reset, stop, close, delete, and recreate via the recorder"],"tags":["concurrency","file-integrity","race-condition"],"backgroundTag":"file-write-failed","analyzedSha":"433685b2024e7bc4c99e1e2e326bcad39b4d9d65","analyzedAt":"2026-09-15T12:24:24.634Z","contentChangedAt":"2026-09-15T12:24:24.634Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}