Hmbown/CodeWhale · error

The previous pet recording has an incomplete final row; it…

Error message

The previous pet recording has an incomplete final row; it was preserved.

What it means

After decoding the resumed bytes as strict UTF-8, the library requires that any non-empty content ends with a newline, meaning the last JSONL row is complete. A file whose final line is truncated (crash or kill mid-write of the previous run) throws this error and preserves the recording instead of resuming onto a partial row that would corrupt the replayable-segment guarantee.

Solutions

  1. Repair the file by appending a newline only if the partial tail is safe to discard, or truncate the incomplete final row so the file ends with \n, then resume.
  2. Keep the corrupt recording as an archive copy and start a new recording at a fresh path instead of resuming.
  3. Prevent recurrence by ensuring the previous writer exits cleanly (flush and close) and is not SIGKILLed mid-write; the recorder's bounded segments make full-segment loss cheaper than tail corruption.

Example fix

// before
await createPetRecorder(path, { resume: true }); // throws: incomplete final row
// after
let text = await readFile(path, 'utf8');
if (text.length && !text.endsWith('\n')) {
  text = text.slice(0, text.lastIndexOf('\n') + 1);
  await writeFile(path, text); // drop the partial row
}
await createPetRecorder(path, { resume: true });
Defensive patterns

Strategy: fallback

Validate before calling

import { readFile, writeFile } from 'node:fs/promises';
async function tailIsComplete(p) {
  let text = await readFile(p, 'utf8').catch(e => e.code === 'ENOENT' ? '' : Promise.reject(e));
  return !text || text.endsWith('\n');
}
async function repairTail(p) {
  let text = await readFile(p, 'utf8');
  if (text && !text.endsWith('\n')) await writeFile(p, text.slice(0, text.lastIndexOf('\n') + 1));
}

Try / catch

try { await createPetRecorder(p, { resume: true }); } catch (e) { if (/incomplete final row/.test(e.message)) { await archiveCopy(p); await truncateIncompleteTail(p); return createPetRecorder(p, { resume: true }); } throw e; }

Prevention

When it happens

Trigger: createPetRecorder(path, { resume: true }) on a file whose last write was cut off mid-row: previous process killed with SIGKILL mid-write, disk-full truncation, or power loss leaving a partial JSON line without a trailing \n.

Common situations: Resuming after a hard crash of the recording CLI; an earlier run terminated by OOM/timeout while serializing a large event; manually edited or piped output missing the final newline.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15). Data as JSON: /api/errors/8ab2c7b5b91a6206. Report an issue: GitHub.

Appendix: source

Thrown at pet/scripts/lib/pet-recorder.mjs:77

      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-`;
      for await (const entry of await opendir(dirname(path))) {
        if (!entry.name.startsWith(prefix)) continue;
        const suffix = entry.name.slice(prefix.length);
        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;

View on GitHub (pinned to 433685b202)