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

  1. Stop all other writers to the recording path; only the recorder should touch it.
  2. Restore the original file (undo the truncation/rewrite) or restart recording from a fresh path.
  3. If a symlink/rotation scheme replaces the file, point the recorder at a dedicated directory nothing else manages.
  4. 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

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


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 is

View on GitHub (pinned to 433685b202)