earendil-works/pi · critical · SessionError
invalid_entry
invalid_entry
Error message
Invalid session mutation: ${message} What it means
SessionState.applyMutation is the replay engine behind durable sessions and enforces the invariants that make the log reconstructible: seq must be exactly previous + 1, entry/record ids must be unique, referenced lanes/parents/label targets must exist, and entries must chain to the lane's current leaf. This error reports the first breached invariant and the message names it (for example 'has non-consecutive seq 5', 'does not chain to the lane leaf', 'references missing parent <id>'). The built-in InMemorySessionStorage always builds conforming mutations, so hitting this means a custom SessionStorage implementation, a hand-rolled log replay, or corrupted persisted log data.
Source
Thrown at packages/agent/src/harness/session/state.ts:27
type LogItem,
type LogOptions,
type OperationStartedRecord,
type RecordQuery,
SessionError,
type SessionStats,
} from "./types.ts";
export type SessionMutation =
| { kind: "entry"; lane?: string; entry: Entry }
| { kind: "record"; record: LaneRecord }
| { kind: "lane"; seq: number; lane: string; leafId: string | null }
| { kind: "fact"; seq: number; fact: "name"; name: string | undefined }
| { kind: "fact"; seq: number; fact: "label"; targetId: string; label: string | undefined };
type InvalidMutation = (message: string) => never;
function invalidMutation(message: string): never {
throw new SessionError("invalid_entry", `Invalid session mutation: ${message}`);
}
function assertValidLimit(limit: number | undefined): void {
if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) {
throw new SessionError("invalid_query", "limit must be a positive integer");
}
}
function assertValidCursor(afterSeq: number | undefined): void {
if (afterSeq !== undefined && (!Number.isInteger(afterSeq) || afterSeq < 0)) {
throw new SessionError("invalid_query", "cursor sequence must be a non-negative integer");
}
}
function* ordered<T>(items: readonly T[], order: EntryOrder | undefined): IterableIterator<T> {
if (order === "oldestFirst") {
yield* items;
return;View on GitHub (pinned to 4af9d21d3b)
Solutions
- Take seq from state.nextSequence at append time — never hardcode or persist-and-reuse it
- Replay logs in strict ascending seq order starting at 1 with no gaps, and verify replayed counts against storage
- Serialize writers so only one append reads nextSequence at a time
- If you only use the public Session / InMemorySessionStorage APIs, report it as a library bug with the failing mutation
Example fix
// before (custom storage replay, order not guaranteed) for (const m of loadedLog) state.applyMutation(m); // after const seqOf = (m) => (m.kind === 'entry' ? m.entry.seq : m.kind === 'record' ? m.record.seq : m.seq); for (const m of [...loadedLog].sort((a, b) => seqOf(a) - seqOf(b))) state.applyMutation(m); // and build new mutations with seq: state.nextSequence
Defensive patterns
Strategy: try-catch
Validate before calling
// before replaying a persisted log into SessionState const seqOf = (m: SessionMutation): number => m.kind === 'entry' ? m.entry.seq : m.kind === 'record' ? m.record.seq : m.seq; const isContiguous = (log: SessionMutation[]): boolean => [...log].sort((a, b) => seqOf(a) - seqOf(b)).every((m, i) => seqOf(m) === i + 1);
Try / catch
try {
state.applyMutation(mutation);
} catch (error) {
if (error instanceof SessionError && error.code === 'invalid_entry') {
// non-retryable: quarantine this session's log and report, do not re-apply the mutation
throw new Error(`corrupt session log: ${error.message}`, { cause: error });
}
throw error;
} Prevention
- Serialize writers so nextSequence reads never race
- Build mutations with seq: state.nextSequence at append time
- Persist logs atomically (write temp file, then rename)
- Round-trip custom storage implementations through replay tests
When it happens
Trigger: A custom storage backend calling state.applyMutation with mutations built out of order or with hardcoded seq values; replaying a persisted log that was truncated or has duplicated items; two concurrent writers both reading nextSequence and appending; appending an entry whose parentId is not the lane's current leaf.
Common situations: Writing alternative persistence (SQLite, flat files) on top of SessionState and forwarding mutations without preserving order; a crash mid-write leaving a partial log; parallel test writers sharing one session state.
Related errors
AI-assisted analysis of earendil-works/pi@4af9d21d3b (2026-08-24).
Data as JSON: /api/errors/010671755d34fc48.
Report an issue: GitHub.