paperclipai/paperclip · error
Attempt journal is closed
Error message
Attempt journal is closed
What it means
AttemptJournal is a one-shot append-only JSONL file writer (opened O_EXCL with mode 0600). append() throws 'Attempt journal is closed' when the internal file descriptor is null, i.e. after close() has been called or the fd was never successfully set. The class treats a closed journal as terminal — no further appends are allowed.
Source
Thrown at packages/paperclip-runner/src/evals/attempt-journal.ts:11
import { closeSync, constants, fsyncSync, openSync, writeSync } from "node:fs";
/** Controller-owned evidence outside disposable server storage. No credentials. */
export class AttemptJournal {
#fd: number | null;
#bytes = 0;
constructor(path: string, readonly maxBytes = 64 * 1024 * 1024) {
this.#fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
}
append(value: unknown): void {
if (this.#fd === null) throw new Error("Attempt journal is closed");
const bytes = Buffer.from(JSON.stringify(value) + "\n");
if (this.#bytes + bytes.length > this.maxBytes) throw new Error("Attempt journal limit reached; stop provider dispatch");
let offset = 0;
while (offset < bytes.length) offset += writeSync(this.#fd, bytes, offset, bytes.length - offset);
fsyncSync(this.#fd);
this.#bytes += bytes.length;
}
close(): void {
if (this.#fd !== null) closeSync(this.#fd);
this.#fd = null;
}
}
View on GitHub (pinned to 01ad858492)
Solutions
- Create a new AttemptJournal per attempt instead of reusing a closed one.
- Reorder cleanup so close() runs only after all dispatch/append activity completes.
- Guard appends with an isOpen check (journal.#fd !== null is private, so track closed state in the owning code).
- If you control the code, make append a no-op-or-log on closed state rather than throwing during shutdown.
Example fix
// before
finally { journal.close(); }
await dispatch(); // append() later throws: journal closed
// after
await dispatch();
finally { journal.close(); } Defensive patterns
Strategy: try-catch
Validate before calling
let journalOpen = true;
function guardedAppend(journal: AttemptJournal, value: unknown) {
if (!journalOpen) return; // closed after cleanup began
journal.append(value);
} Try / catch
try {
journal.append(event);
} catch (err) {
if ((err as Error).message === 'Attempt journal is closed') {
logger.warn('append after journal close; dropping event');
return;
}
throw err;
} finally {
// close only after all appends are done
} Prevention
- Scope the journal to a single attempt and close it exactly once, after all dispatch completes.
- Never share one journal across attempt lifecycles (the O_EXCL constructor enforces one file per attempt).
- Register close() as the very last cleanup step, not in an early finally.
- Track open/closed state in the owning code to short-circuit appends during shutdown.
When it happens
Trigger: Calling append() after close(); reusing a journal object across attempt lifecycles where close was already invoked; a shutdown/cleanup path closing the journal while provider dispatch is still running and appending.
Common situations: Finally-blocks closing the journal before async dispatch callbacks finish; error paths that close early then retry dispatch; wrapping a single journal in multiple attempt loops without recreating it (the O_EXCL constructor also fails if the file exists, so journals are per-attempt by design).
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
- Attempt journal limit reached; stop provider dispatch
- PRP provider thread is not started
- provider turn ended with status ${turn.status}
- Plugin UI is not available (status: ${plugin.status})
- Plugin is not ready (current status: ${plugin.status})
AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10).
Data as JSON: /api/errors/b528f9c3a98ac8db.
Report an issue: GitHub.