Hmbown/CodeWhale · error · Error
The live pet recording was changed or replaced externally…
Error message
The live pet recording was changed or replaced externally; existing files were preserved.
What it means
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.
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.
Example fix
// before: external job truncates the file truncate -s 0 pet-recording.jsonl // after: let only the recorder manage it // remove the truncate job; to start fresh, close() then delete + recreate via the recorder
Defensive patterns
Strategy: try-catch
Validate before calling
const held = await fs.stat(path, { bigint: true });
const current = await fs.lstat(path, { bigint: true });
if (!current.isFile() || held.ino !== current.ino) throw new Error('recording path was replaced; restart recorder'); Try / catch
try {
await recorder.append(bucket);
} catch (e) {
if (e.message.includes('changed or replaced externally')) {
// disk state won: stop writing, inspect the file, restart if safe
await recorder.close().catch(() => {});
} else throw e;
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Understand the failure class
Background: "failed to write file", "Could not save figure", "Error saving remote file" — file write failed: causes and fixes across languages and libraries — this error's family across 38 libraries.
Related errors
- ; additionally the Codewhale-owned legacy secret slot…
- telemetry permission changed before arming
- The live pet recording changed while rotating; existing…
- The live pet recording was replaced externally after…
- The previous pet recording changed while reading; it was…
AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15).
Data as JSON: /api/errors/eeefe7c444844ad5.
Report an issue: GitHub.
Appendix: source
Thrown at pet/scripts/lib/pet-recorder.mjs:106
if (!/^[0-9]{6,}\.jsonl$/.test(suffix)) continue;
const number = Number(suffix.slice(0, -6));
if (!Number.isSafeInteger(number) || number >= Number.MAX_SAFE_INTEGER)
throw new Error('Pet archive numbering is exhausted; existing files were preserved.');
segment = Math.max(segment, number);
}
}
expectedMtime ??= (await output.stat({ bigint: true })).mtimeNs;
} catch (error) { try { await output?.close(); } finally { lock.close(); } throw error; }
return {
async append(bucket) {
if (!output || busy) throw new Error('Pet recorder is closed or already writing.');
validatePetBucket(bucket);
busy = true;
try {
await lock.check();
const held = await output.stat({ bigint: true }), current = await lstat(path, { bigint: true });
if (!current.isFile() || held.dev !== current.dev || held.ino !== current.ino || held.size !== BigInt(bytes) || held.mtimeNs !== expectedMtime)
throw new Error('The live pet recording was changed or replaced externally; existing files were preserved.');
const encode = seq => encodePetJSONL([{ ...bucket, sequence: seq, simTimeMs: seq * PET_BIN_MS }]);
let row = encode(sequence), size = Buffer.byteLength(row);
if (restart || sequence >= maxBuckets || bytes + size > maxBytes) {
row = encode(0); size = Buffer.byteLength(row);
if (size > maxBytes) throw new Error('Pet bucket exceeds the recording segment byte limit.');
const temporary = resolve(dirname(path), `.${basename(path)}.next-${randomUUID()}`);
const archive = `${path}.segment-${String(segment + 1).padStart(6, '0')}.jsonl`;
let next, installed = false, created = false;
try {
next = await open(temporary, 'wx', 0o600); created = true;
await next.writeFile(row); await next.sync();
const nextIdentity = await next.stat({ bigint: true });
await next.close(); next = undefined;
await output.sync();
// link is exclusive: a collision never replaces someone else's
// archive. Persist this name before replacing the live pathname.
await link(path, archive); await syncDirectory(dirname(path));
// Windows can reject replacement while either writer handle isView on GitHub (pinned to 433685b202)