feat(session): define explicit fork boundaries

This commit is contained in:
Dax Raad 2026-07-29 09:49:35 -04:00
commit fc11ed3838
29 changed files with 821 additions and 386 deletions

View file

@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
"id": "a4ba73b4-21bc-41ab-a415-94e2ca38d798",
"id": "db37a97f-9b5e-4c87-be8b-4feace35136c",
"prevIds": [
"5f0a1db8-d4bf-42c3-becb-96b46fe66bed"
"a4ba73b4-21bc-41ab-a415-94e2ca38d798"
],
"ddl": [
{
@ -1266,17 +1266,7 @@
"autoincrement": false,
"default": null,
"generated": null,
"name": "fork_message_id",
"entityType": "columns",
"table": "session"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "fork_seq",
"name": "fork_boundary",
"entityType": "columns",
"table": "session"
},

View file

@ -57,5 +57,6 @@ export const migrations = (
import("./migration/20260716020354_kv"),
import("./migration/20260722011141_delete_tool_progress_events"),
import("./migration/20260722170000_canonical_tool_results"),
import("./migration/20260729022634_session_fork_boundary"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260729022634_session_fork_boundary",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_boundary\` text;`)
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_message_id\`;`)
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_seq\`;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -213,8 +213,7 @@ export default {
\`workspace_id\` text,
\`parent_id\` text,
\`fork_session_id\` text,
\`fork_message_id\` text,
\`fork_seq\` integer,
\`fork_boundary\` text,
\`slug\` text NOT NULL,
\`directory\` text NOT NULL,
\`path\` text,

View file

@ -28,11 +28,12 @@ import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution"
import { MessageDecodeError, NotFoundError } from "./session/error"
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LocationServiceMap } from "./location-service-map"
import { SessionEvent } from "./session/event"
import { SessionPending } from "./session/pending"
import { InstructionState } from "./session/instruction-state"
import { SessionGenerate } from "./session/generate"
import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert"
@ -106,7 +107,7 @@ type CompactInput = {
type ForkInput = {
sessionID: SessionSchema.ID
messageID?: SessionMessage.ID
boundary: Session.ForkRequestBoundary
}
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
@ -181,7 +182,9 @@ export interface Interface {
readonly data: SessionSchema.Info[]
}>
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly fork: (input: ForkInput) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError>
readonly fork: (
input: ForkInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly messages: (input: {
@ -395,25 +398,33 @@ const layer = Layer.effect(
}),
fork: Effect.fn("Session.fork")(function* (input) {
const parent = yield* result.get(input.sessionID)
const boundary = input.messageID
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)),
)
.get()
.pipe(Effect.orDie)
: undefined
if (input.messageID && !boundary)
return yield* new MessageNotFoundError({ sessionID: input.sessionID, messageID: input.messageID })
const boundary = yield* db
.select({ id: SessionMessageTable.id, seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, input.sessionID),
input.boundary.type === "before" ? eq(SessionMessageTable.id, input.boundary.messageID) : undefined,
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!boundary && input.boundary.type === "before")
return yield* new MessageNotFoundError({
sessionID: input.sessionID,
messageID: input.boundary.messageID,
})
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
const sessionID = SessionSchema.ID.create()
const parentSeq = boundary ? boundary.seq - 1 : yield* Bus.latestSequence(db, parent.id)
const instructionThrough =
input.boundary.type === "before" ? boundary.seq - 1 : yield* Bus.latestSequence(db, parent.id)
yield* bus.publish(SessionEvent.Forked, {
sessionID,
parentID: parent.id,
parentSeq,
from: input.messageID,
boundary: { ...input.boundary, messageID: boundary.id },
instructions: yield* InstructionState.valuesAt(db, parent.id, instructionThrough),
})
return yield* result.get(sessionID).pipe(Effect.orDie)
}),

View file

@ -10,6 +10,14 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
sessionID: SessionSchema.ID,
}) {}
export class ForkEmptyError extends Schema.TaggedErrorClass<ForkEmptyError>()("Session.ForkEmptyError", {
sessionID: SessionSchema.ID,
}) {
override get message() {
return `Cannot fork empty session: ${this.sessionID}`
}
}
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,

View file

@ -8,7 +8,6 @@ import { AbsolutePath, RelativePath } from "../schema"
import { Workspace } from "../workspace"
import { SessionSchema } from "./schema"
import { SessionTable } from "./sql"
import { SessionMessage } from "./message"
import { PersistedRevert } from "@opencode-ai/schema/session-revert"
import { Money } from "@opencode-ai/schema/money"
@ -20,12 +19,13 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
projectID: Project.ID.make(row.project_id),
title: row.title,
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
fork: row.fork_session_id
? {
sessionID: SessionSchema.ID.make(row.fork_session_id),
messageID: row.fork_message_id ? SessionMessage.ID.make(row.fork_message_id) : undefined,
}
: undefined,
fork:
row.fork_session_id && row.fork_boundary
? {
sessionID: SessionSchema.ID.make(row.fork_session_id),
boundary: row.fork_boundary,
}
: undefined,
agent: row.agent ? Agent.ID.make(row.agent) : undefined,
model: row.model
? {

View file

@ -10,11 +10,12 @@ import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { Event } from "@opencode-ai/schema/event"
import { SessionSchema } from "./schema"
import { InstructionBlobTable, InstructionStateTable, SessionTable } from "./sql"
import { InstructionBlobTable, InstructionStateTable } from "./sql"
type DatabaseService = Database.Interface["db"]
const decodeInstructionsUpdated = Schema.decodeUnknownSync(SessionEvent.InstructionsUpdated.data)
const decodeForked = Schema.decodeUnknownSync(SessionEvent.Forked.data)
export interface Observation extends Instructions.Admission {
readonly sessionID: SessionSchema.ID
@ -94,6 +95,26 @@ export const apply = Effect.fn("InstructionState.apply")(function* (
.pipe(Effect.orDie)
})
export const initialize = Effect.fn("InstructionState.initialize")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
seq: number,
values: Instructions.Values,
) {
yield* db
.insert(InstructionStateTable)
.values({
session_id: sessionID,
epoch_start: seq,
through_seq: seq,
initial_values: values,
current_values: values,
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
})
export const advanceEpoch = Effect.fn("InstructionState.advanceEpoch")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
@ -255,6 +276,14 @@ const stateFromEvents = Effect.fnUntraced(function* (db: DatabaseService, sessio
return folded ? foldedState(sessionID, folded) : undefined
})
export const valuesAt = Effect.fn("InstructionState.valuesAt")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
through: number,
) {
return fold(yield* instructionEvents(db, sessionID, through))?.current
})
const latestRelevantSequence = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select({ seq: EventTable.seq })
@ -324,15 +353,23 @@ const revertedEventType = Bus.versionedType(
SessionEvent.RevertEvent.Committed.type,
SessionEvent.RevertEvent.Committed.durable.version,
)
const relevantEventTypes = [instructionEventType, compactionEventType, movedEventType, revertedEventType]
const forkedEventType = Bus.versionedType(SessionEvent.Forked.type, SessionEvent.Forked.durable.version)
const relevantEventTypes = [
forkedEventType,
instructionEventType,
compactionEventType,
movedEventType,
revertedEventType,
]
type InstructionEventRow = typeof EventTable.$inferSelect
const instructionEvents = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
through?: number,
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
return yield* eventRows(db, sessionID, relevantEventTypes)
return yield* eventRows(db, sessionID, relevantEventTypes, undefined, through)
})
const instructionUpdatesAfter = Effect.fnUntraced(function* (
@ -348,48 +385,22 @@ const eventRows = Effect.fnUntraced(function* (
sessionID: SessionSchema.ID,
types: ReadonlyArray<string>,
after?: number,
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
const segments = (yield* lineage(db, sessionID)).filter(
(segment) => after === undefined || segment.through === undefined || segment.through > after,
)
return (yield* Effect.forEach(segments, (segment) =>
db
.select()
.from(EventTable)
.where(
and(
eq(EventTable.aggregate_id, segment.sessionID),
inArray(EventTable.type, types),
segment.through === undefined ? undefined : lte(EventTable.seq, segment.through),
after === undefined ? undefined : gt(EventTable.seq, after),
),
)
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie),
)).flat()
})
const lineage = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
through?: number,
): Effect.fn.Return<ReadonlyArray<{ readonly sessionID: SessionSchema.ID; readonly through?: number }>> {
const session = yield* db
.select({ parentID: SessionTable.fork_session_id, forkSeq: SessionTable.fork_seq })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
return yield* db
.select()
.from(EventTable)
.where(
and(
eq(EventTable.aggregate_id, sessionID),
inArray(EventTable.type, types),
after === undefined ? undefined : gt(EventTable.seq, after),
through === undefined ? undefined : lte(EventTable.seq, through),
),
)
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
const inherited =
session?.parentID && session.forkSeq !== null
? yield* lineage(
db,
session.parentID,
through === undefined ? session.forkSeq : Math.min(session.forkSeq, through),
)
: []
return [...inherited, { sessionID, ...(through === undefined ? {} : { through }) }]
})
function fold(rows: ReadonlyArray<InstructionEventRow>) {
@ -402,6 +413,12 @@ function fold(rows: ReadonlyArray<InstructionEventRow>) {
}
| undefined
>((state, row) => {
if (row.type === forkedEventType) {
const instructions = decodeForked(row.data).instructions
return instructions
? { epochStart: row.seq, throughSeq: row.seq, initial: instructions, current: instructions }
: undefined
}
if (row.type === movedEventType || row.type === revertedEventType) return undefined
if (row.type === compactionEventType)
return state

View file

@ -1,6 +1,6 @@
export * as SessionProjector from "./projector"
import { and, asc, desc, eq, gt, gte, inArray, lt, sql } from "drizzle-orm"
import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import { Database } from "../database/database"
import { Bus } from "../bus"
@ -174,25 +174,28 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.get()
.pipe(Effect.orDie)
if (!parent) return yield* Effect.die(new Error(`Fork parent session not found: ${event.data.parentID}`))
const boundary = event.data.from
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.from)),
)
.get()
.pipe(Effect.orDie)
: undefined
if (event.data.from && !boundary)
return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.from}`))
const boundary = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.parentID),
eq(SessionMessageTable.id, event.data.boundary.messageID),
),
)
.get()
.pipe(Effect.orDie)
if (!boundary)
return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.boundary.messageID}`))
const copied = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.parentID),
boundary === undefined ? undefined : lt(SessionMessageTable.seq, boundary.seq),
event.data.boundary.type === "before"
? lt(SessionMessageTable.seq, boundary.seq)
: lte(SessionMessageTable.seq, boundary.seq),
),
)
.orderBy(desc(SessionMessageTable.seq))
@ -207,8 +210,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
id: event.data.sessionID,
parent_id: null,
fork_session_id: event.data.parentID,
fork_message_id: event.data.from,
fork_seq: event.data.parentSeq,
fork_boundary: event.data.boundary,
project_id: parent.project_id,
workspace_id: parent.workspace_id,
slug: Slug.create(),
@ -314,8 +316,9 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
cursor = rows.at(-1)!.seq
}
yield* Bus.reserveSequence(db, event.data.sessionID, event.data.parentSeq)
yield* InstructionState.rebuild(db, event.data.sessionID)
if (copiedSeq !== undefined) yield* Bus.reserveSequence(db, event.data.sessionID, copiedSeq)
if (event.data.instructions)
yield* InstructionState.initialize(db, event.data.sessionID, event.durable.seq, event.data.instructions)
})
function run(db: DatabaseService, event: MessageEvent) {

View file

@ -32,8 +32,7 @@ export const SessionTable = sqliteTable(
workspace_id: text().$type<Workspace.ID>(),
parent_id: text().$type<SessionSchema.ID>(),
fork_session_id: text().$type<SessionSchema.ID>(),
fork_message_id: text().$type<SessionMessage.ID>(),
fork_seq: integer(),
fork_boundary: text({ mode: "json" }).$type<Session.ForkBoundary>(),
slug: text().notNull(),
directory: directoryColumn().notNull(),
path: pathColumn(),

View file

@ -199,7 +199,7 @@ describe("Session.create", () => {
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
yield* SessionPending.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id })
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const parentContext = yield* session.context(parent.id)
const forkContext = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
@ -252,6 +252,17 @@ describe("Session.create", () => {
}),
)
it.effect("rejects forking an empty session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const parent = yield* session.create({ location })
expect(
yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.ForkEmptyError", sessionID: parent.id })
}),
)
it.effect("forks before the selected boundary message", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@ -286,16 +297,27 @@ describe("Session.create", () => {
tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } },
})
const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id })
const beforeFirst = yield* session.fork({ sessionID: parent.id, messageID: first.id })
const complete = yield* session.fork({ sessionID: parent.id })
const forked = yield* session.fork({
sessionID: parent.id,
boundary: { type: "before", messageID: second.id },
})
const beforeFirst = yield* session.fork({
sessionID: parent.id,
boundary: { type: "before", messageID: first.id },
})
const complete = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const context = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
expect(forked.fork).toEqual({ sessionID: parent.id, messageID: second.id })
expect(forked.fork).toEqual({
sessionID: parent.id,
boundary: { type: "before", messageID: second.id },
})
expect(context).toMatchObject([{ text: "First" }])
expect(context[0]?.id).not.toBe(first.id)
expect(history[0]).toMatchObject({ data: { from: second.id } })
expect(history[0]).toMatchObject({
data: { boundary: { type: "before", messageID: second.id } },
})
expect(forked).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } })
expect(yield* session.context(beforeFirst.id)).toEqual([])
expect(beforeFirst).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } })

View file

@ -1107,7 +1107,7 @@ describe("SessionRunnerLLM", () => {
systemBaseline = "Latest context"
yield* runPrompt(session, "Third")
const forked = yield* session.fork({ sessionID, messageID: second.id })
const forked = yield* session.fork({ sessionID, boundary: { type: "before", messageID: second.id } })
expect(
yield* (yield* Database.Service).db
.select()
@ -1115,14 +1115,13 @@ describe("SessionRunnerLLM", () => {
.where(eq(InstructionStateTable.session_id, forked.id))
.get(),
).toMatchObject({
initial_values: { "test/context": Instructions.hash("Initial context") },
initial_values: { "test/context": Instructions.hash("Changed context") },
current_values: { "test/context": Instructions.hash("Changed context") },
})
yield* session.prompt({ sessionID: forked.id, text: "Forked", resume: false })
yield* session.resume(forked.id)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"])
expect(systemTexts(requests.at(-1)!)).toContain("Latest context")
const { db } = yield* Database.Service
@ -1151,19 +1150,22 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("caps nested fork instruction ancestry at the selected message", () =>
it.effect("keeps nested forks self-contained", () =>
Effect.gen(function* () {
const session = yield* setup
yield* runPrompt(session, "First")
systemBaseline = "Changed context"
const second = yield* runPrompt(session, "Second")
const child = yield* session.fork({ sessionID, messageID: second.id })
const child = yield* session.fork({ sessionID, boundary: { type: "before", messageID: second.id } })
const inheritedFirst = (yield* session.messages({ sessionID: child.id })).find(
(message) => message.type === "user" && message.text === "First",
)
if (!inheritedFirst) return yield* Effect.die(new Error("Nested fork boundary message not found"))
const grandchild = yield* session.fork({ sessionID: child.id, messageID: inheritedFirst.id })
const grandchild = yield* session.fork({
sessionID: child.id,
boundary: { type: "before", messageID: inheritedFirst.id },
})
expect(
yield* (yield* Database.Service).db
@ -1172,8 +1174,8 @@ describe("SessionRunnerLLM", () => {
.where(eq(InstructionStateTable.session_id, grandchild.id))
.get(),
).toMatchObject({
initial_values: { "test/context": Instructions.hash("Initial context") },
current_values: { "test/context": Instructions.hash("Initial context") },
initial_values: { "test/context": Instructions.hash("Changed context") },
current_values: { "test/context": Instructions.hash("Changed context") },
})
return undefined
}),