earendil-works/pi · error · SessionError
invalid_fork_target
invalid_fork_target
Error message
Fork target is not a message entry: ${selectedEntryId} What it means
When forking with branch scope (anything other than scope: 'tree'), createForkMutations anchors the copy at a message entry: options.entryId when given, otherwise the main lane leaf from requireLane('main') (state.ts:267). If that id is missing from entriesById or its entry.type is not 'message', the fork is refused with invalid_fork_target. Records and non-message entries cannot anchor a branch fork.
Source
Thrown at packages/agent/src/harness/session/state.ts:272
}
getStats(): SessionStats {
return this.stats;
}
createForkMutations(options: ForkOptions): SessionMutation[] {
let copiedEntries: Entry[];
let forkLanes: LanePointer[];
if (options.scope === "tree") {
copiedEntries = this.findEntries({ order: "oldestFirst" });
forkLanes = this.getLanes();
} else {
const selectedEntryId = options.entryId ?? this.requireLane("main");
let targetId: string | null = null;
if (selectedEntryId !== null) {
const entry = this.getEntry(selectedEntryId);
if (!entry || entry.type !== "message") {
throw new SessionError("invalid_fork_target", `Fork target is not a message entry: ${selectedEntryId}`);
}
const position = options.position ?? (options.entryId === undefined ? "at" : "before");
targetId = position === "at" ? entry.id : entry.parentId;
}
copiedEntries = targetId === null ? [] : this.findEntriesOnBranch({ start: targetId, order: "oldestFirst" });
forkLanes = [{ lane: "main", leafId: targetId }];
}
const mutations: SessionMutation[] = [];
let sequence = 1;
for (const sourceEntry of copiedEntries) {
mutations.push({ kind: "entry", entry: { ...structuredClone(sourceEntry), seq: sequence++ } });
}
for (const pointer of forkLanes) {
mutations.push({ kind: "lane", seq: sequence++, lane: pointer.lane, leafId: pointer.leafId });
}
if (this.name !== undefined) {
mutations.push({ kind: "fact", seq: sequence++, fact: "name", name: this.name });View on GitHub (pinned to 4af9d21d3b)
Solutions
- Pick the fork point from state.findEntries({ type: 'message' })
- Pass position ('at' or 'before') explicitly; 'before' resolves to entry.parentId, which must exist
- Use scope: 'tree' when you want the whole session copied without a message anchor
- Guard with state.getEntry(id)?.type === 'message' before calling fork
Example fix
// before
await session.fork({ entryId: runId }); // record id -> invalid_fork_target
// after
const anchor = state.findEntries({ type: 'message' }).at(-1)!;
await session.fork({ entryId: anchor.id, position: 'at' }); Defensive patterns
Strategy: validation
Validate before calling
// Branch forks need a message entry anchor
const anchorId = options.entryId ?? state.requireLane('main');
const anchorOk = anchorId === null || state.getEntry(anchorId)?.type === 'message';
if (anchorOk) await session.fork(options);
else await session.fork({ scope: 'tree' }); Type guard
function isMessageEntryId(state: SessionState, id: string): boolean {
return state.getEntry(id)?.type === 'message';
} Try / catch
try {
await session.fork(options);
} catch (e) {
if (isSessionError(e, 'invalid_fork_target')) {
await session.fork({ scope: 'tree' }); // fall back to a full-tree fork
} else throw e;
} Prevention
- Source fork points from findEntries({ type: 'message' })
- Don't pass runIds or record ids as entryId
- When forking the lane tip, ensure the leaf is a message entry or pass entryId explicitly
When it happens
Trigger: fork({ entryId }) where entryId is a record id from findRecords(); fork() with no entryId while main's leaf is a non-message entry (for example a custom entry appended last); an entryId copied from another session or typo'd, so getEntry returns undefined and the same error fires.
Common situations: Passing a tool run's runId where an entry id is expected; forking after appending system or custom entries so the lane tip is no longer a message; a UI listing getLog() items that lets users pick a record as the fork point.
Related errors
AI-assisted analysis of earendil-works/pi@4af9d21d3b (2026-08-24).
Data as JSON: /api/errors/8dd8299004875d08.
Report an issue: GitHub.