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
- 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.
- Keep the corrupt recording as an archive copy and start a new recording at a fresh path instead of resuming.
- 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
- Terminate recorder processes with graceful shutdown (SIGINT/SIGTERM + flush/close), not SIGKILL, to keep rows complete.
- Before resuming after a crash, check the file ends with \n and truncate any partial tail row.
- Validate the tail JSON line parses with decodePetJSONL-equivalent logic before appending to a crashed recording.
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
- Non-contiguous pet tape.
- Agent has no continuable checkpoint to resume from…
- Cannot resume agent on its saved provider
- Cannot resume agent : sub-agent depth limit reached…
- ; additionally could not verify secret-store rollback for
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)