{"record":{"id":"8dd8299004875d08","repo":"earendil-works/pi","slug":"invalid-fork-target","errorCode":"invalid_fork_target","errorMessage":"Fork target is not a message entry: ${selectedEntryId}","messagePattern":"Fork target is not a message entry: (.+?)","errorType":"exception","errorClass":"SessionError","httpStatus":null,"severity":"error","filePath":"packages/agent/src/harness/session/state.ts","lineNumber":272,"sourceCode":"\t}\n\n\tgetStats(): SessionStats {\n\t\treturn this.stats;\n\t}\n\n\tcreateForkMutations(options: ForkOptions): SessionMutation[] {\n\t\tlet copiedEntries: Entry[];\n\t\tlet forkLanes: LanePointer[];\n\t\tif (options.scope === \"tree\") {\n\t\t\tcopiedEntries = this.findEntries({ order: \"oldestFirst\" });\n\t\t\tforkLanes = this.getLanes();\n\t\t} else {\n\t\t\tconst selectedEntryId = options.entryId ?? this.requireLane(\"main\");\n\t\t\tlet targetId: string | null = null;\n\t\t\tif (selectedEntryId !== null) {\n\t\t\t\tconst entry = this.getEntry(selectedEntryId);\n\t\t\t\tif (!entry || entry.type !== \"message\") {\n\t\t\t\t\tthrow new SessionError(\"invalid_fork_target\", `Fork target is not a message entry: ${selectedEntryId}`);\n\t\t\t\t}\n\t\t\t\tconst position = options.position ?? (options.entryId === undefined ? \"at\" : \"before\");\n\t\t\t\ttargetId = position === \"at\" ? entry.id : entry.parentId;\n\t\t\t}\n\t\t\tcopiedEntries = targetId === null ? [] : this.findEntriesOnBranch({ start: targetId, order: \"oldestFirst\" });\n\t\t\tforkLanes = [{ lane: \"main\", leafId: targetId }];\n\t\t}\n\n\t\tconst mutations: SessionMutation[] = [];\n\t\tlet sequence = 1;\n\t\tfor (const sourceEntry of copiedEntries) {\n\t\t\tmutations.push({ kind: \"entry\", entry: { ...structuredClone(sourceEntry), seq: sequence++ } });\n\t\t}\n\t\tfor (const pointer of forkLanes) {\n\t\t\tmutations.push({ kind: \"lane\", seq: sequence++, lane: pointer.lane, leafId: pointer.leafId });\n\t\t}\n\t\tif (this.name !== undefined) {\n\t\t\tmutations.push({ kind: \"fact\", seq: sequence++, fact: \"name\", name: this.name });","sourceCodeStart":254,"sourceCodeEnd":290,"githubUrl":"https://github.com/earendil-works/pi/blob/4af9d21d3b4d664e4a29fcabfec85171077248e3/packages/agent/src/harness/session/state.ts#L254-L290","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before\nawait session.fork({ entryId: runId }); // record id -> invalid_fork_target\n\n// after\nconst anchor = state.findEntries({ type: 'message' }).at(-1)!;\nawait session.fork({ entryId: anchor.id, position: 'at' });","handlingStrategy":"validation","validationCode":"// Branch forks need a message entry anchor\nconst anchorId = options.entryId ?? state.requireLane('main');\nconst anchorOk = anchorId === null || state.getEntry(anchorId)?.type === 'message';\n\nif (anchorOk) await session.fork(options);\nelse await session.fork({ scope: 'tree' });","typeGuard":"function isMessageEntryId(state: SessionState, id: string): boolean {\n  return state.getEntry(id)?.type === 'message';\n}","tryCatchPattern":"try {\n  await session.fork(options);\n} catch (e) {\n  if (isSessionError(e, 'invalid_fork_target')) {\n    await session.fork({ scope: 'tree' }); // fall back to a full-tree fork\n  } else throw e;\n}","preventionTips":["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"],"tags":["session","fork","entry-type","validation"],"backgroundTag":"invalid-target-type","analyzedSha":"4af9d21d3b4d664e4a29fcabfec85171077248e3","analyzedAt":"2026-08-24T13:07:14.692Z","schemaVersion":2},"datasetVersion":"2026-08-24T17:17:21.512Z"}