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

  1. Pick the fork point from state.findEntries({ type: 'message' })
  2. Pass position ('at' or 'before') explicitly; 'before' resolves to entry.parentId, which must exist
  3. Use scope: 'tree' when you want the whole session copied without a message anchor
  4. 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

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.