Hmbown/CodeWhale · error
The previous pet recording changed while opening; it was…
Error message
The previous pet recording changed while opening; it was preserved.
What it means
After lstat passes, resume mode reopens the file with O_NOFOLLOW and stats the opened handle, comparing dev, ino, and size against the lstat snapshot. A mismatch means the path was swapped (e.g. replaced by a new file or a symlink race) between the check and the open, or something appended to it concurrently. The library aborts, preserving whatever recording is there rather than corrupting it.
Solutions
- Ensure only one recorder process owns the path at a time; the recorder takes a lock via lockRecorder, so check for stale locks or competing processes and remove the redundant one.
- Re-run the resume after the concurrent writer finishes, or point this recorder at a distinct output path.
- If a rotation daemon touches the directory, exclude the active recording path from rotation or pause it during recording.
Example fix
// before
// two concurrent invocations:
await createPetRecorder(sharedPath, { resume: true });
await createPetRecorder(sharedPath, { resume: true });
// after
const isLocked = await lockHeld(sharedPath);
if (!isLocked) await createPetRecorder(sharedPath, { resume: true }); Defensive patterns
Strategy: try-catch
Validate before calling
import { lstat } from 'node:fs/promises';
async function pathIsStable(p, ms = 250) {
const a = await lstat(p, { bigint: true });
await new Promise(r => setTimeout(r, ms));
const b = await lstat(p, { bigint: true });
return a.dev === b.dev && a.ino === b.ino && a.size === b.size;
} Try / catch
try { await createPetRecorder(p, { resume: true }); } catch (e) { if (/changed while opening/.test(e.message)) { await backoff(() => createPetRecorder(p, { resume: true })); } else throw e; } Prevention
- Run at most one recorder per path; rely on the library's lock and surface 'already locked' to users.
- Exclude recording directories from log-rotation/cleanup daemons.
- Wait for size/mtime stability before starting a resume after a crash.
When it happens
Trigger: createPetRecorder(path, { resume: true }) while another process concurrently: replaces the file via rename over the path, deletes and recreates it, swaps in a symlink, or appends data between the lstat and the open/stat of the handle.
Common situations: Two instances of the CLI started at once targeting the same recording path; a log-rotation tool renaming files in the recordings directory; an IDE/test runner cleaning and recreating output paths mid-launch.
Related errors
- The previous pet recording changed while reading; it was…
- The previous pet recording is not a bounded regular file…
- external credential path was redirected while opening
- failed to create owned skill directory
- file is not a bounded regular single-link file
AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15).
Data as JSON: /api/errors/dbba9966268c47de.
Report an issue: GitHub.
Appendix: source
Thrown at pet/scripts/lib/pet-recorder.mjs:66
/** Replaces the CLI's unbounded append-only output. Each complete segment is
* replayable on its own; the same live pathname always holds the newest one. */
export async function createPetRecorder(path, { maxBuckets = 216_000, maxBytes = 64 * 1024 * 1024, report = () => {}, resume = false } = {}) {
if (!Number.isSafeInteger(maxBuckets) || maxBuckets < 1 || maxBuckets > 216_000
|| !Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 64 * 1024 * 1024)
throw new Error('Invalid pet recording segment limit.');
path = resolve(await realpath(dirname(resolve(path))), basename(path));
let lock = await lockRecorder(path), output, sequence = 0, bytes = 0, segment = 0, busy = false, restart = false, expectedMtime;
try {
try { output = await open(path, 'wx', 0o600); }
catch (error) {
if (!resume || error.code !== 'EEXIST') throw error;
const original = await lstat(path, { bigint: true });
if (!original.isFile() || original.size > 64n * 1024n * 1024n)
throw new Error('The previous pet recording is not a bounded regular file; it was preserved.');
output = await open(path, constants.O_RDWR | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const held = await output.stat({ bigint: true });
if (held.dev !== original.dev || held.ino !== original.ino || held.size !== original.size)
throw new Error('The previous pet recording changed while opening; it was preserved.');
// Read at most the size already checked, including a single growth byte.
const contents = Buffer.alloc(Number(held.size) + 1);
let length = 0;
while (length < contents.length) {
const { bytesRead } = await output.read(contents, length, contents.length - length, length);
if (!bytesRead) break;
length += bytesRead;
}
if (length !== Number(held.size)) throw new Error('The previous pet recording changed while reading; it was preserved.');
const text = new TextDecoder('utf-8', { fatal: true }).decode(contents.subarray(0, length));
if (text && !text.endsWith('\n')) throw new Error('The previous pet recording has an incomplete final row; it was preserved.');
decodePetJSONL(text);
const unchanged = await output.stat({ bigint: true });
if (unchanged.size !== original.size || unchanged.mtimeNs !== original.mtimeNs)
throw new Error('The previous pet recording changed while reading; it was preserved.');
bytes = length; restart = true; expectedMtime = original.mtimeNs;
// Continue archive numbering without collecting a growing directory list.
const prefix = `${basename(path)}.segment-`;View on GitHub (pinned to 433685b202)