From 7c6ecaaca8503fb3263146f943eed9cd9af5be97 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 21 Aug 2026 19:46:49 -0400 Subject: [PATCH] fix(core): inherit fork instruction entries (#44004) --- packages/client/src/effect/api/api.ts | 1 + .../client/src/promise/generated/types.ts | 28 +++++---- packages/core/src/session.ts | 11 +++- .../core/src/session/instruction-entry.ts | 50 ++++++++++++++++ packages/core/src/session/projector.ts | 4 ++ packages/core/test/session-create.test.ts | 57 +++++++++++++++++++ packages/schema/src/instruction-entry.ts | 8 +++ packages/schema/src/session-event.ts | 2 + 8 files changed, 150 insertions(+), 11 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 02065dad17..c1cdc0d401 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -410,6 +410,7 @@ export type Endpoint5_31Output = readonly instructions?: | { readonly [x: string & Brand.Brand<"Instruction.Key">]: string & Brand.Brand<"Instruction.Hash"> } | undefined + readonly instructionEntries?: InstructionEntry.Snapshot | undefined } } | { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 0a3354683e..59a4bf96a0 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -263,6 +263,8 @@ export type SessionInboxCompaction = { export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue } +export type InstructionEntrySnapshot = Array<{ key: InstructionEntryKey; value: JsonValue; removed: boolean }> + export type SessionCreated = { id: string created: number @@ -344,16 +346,6 @@ export type SessionDeleted = { data: { sessionID: string } } -export type SessionForked = { - id: string - created: number - metadata?: { [x: string]: any } - type: "session.forked" - durable: { aggregateID: string; seq: number; version: 2 } - location?: LocationRef - data: { sessionID: string; parentID: string; boundary: SessionForkBoundary; instructions?: { [x: string]: string } } -} - export type SessionInboxDelivered = { id: string created: number @@ -1312,6 +1304,22 @@ export type FormWhen = { export type ToolContent = ToolTextContent | ToolFileContent +export type SessionForked = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.forked" + durable: { aggregateID: string; seq: number; version: 2 } + location?: LocationRef + data: { + sessionID: string + parentID: string + boundary: SessionForkBoundary + instructions?: { [x: string]: string } + instructionEntries?: InstructionEntrySnapshot + } +} + export type SessionShellStarted = { id: string created: number diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index b7e4955652..4721af5c8c 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -54,6 +54,7 @@ import { KeyedMutex } from "./effect/keyed-mutex.js" import { fileURLToPath } from "url" import { SessionEnvironment } from "./session/environment.js" import { SessionHistory } from "./session/history.js" +import { InstructionEntry } from "./session/instruction-entry.js" // get project -> project.locations // @@ -437,6 +438,14 @@ const layer = Layer.effect( }) if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID }) const sessionID = SessionSchema.ID.create() + const inherited = yield* db + .transaction(() => + Effect.all({ + instructions: InstructionState.current(db, parent.id), + instructionEntries: InstructionEntry.snapshot(db, parent.id), + }), + ) + .pipe(Effect.orDie) // The fork adopts the parent's newest instruction values rather than the // values in effect at the boundary; copied history may contain frozen // instruction-update text the initial baseline already reflects. @@ -444,7 +453,7 @@ const layer = Layer.effect( sessionID, parentID: parent.id, boundary: { ...input.boundary, messageID: boundary.id }, - instructions: yield* InstructionState.current(db, parent.id), + ...inherited, }) return yield* result.get(sessionID).pipe(Effect.orDie) }), diff --git a/packages/core/src/session/instruction-entry.ts b/packages/core/src/session/instruction-entry.ts index 990c3bcdb2..21c652d88a 100644 --- a/packages/core/src/session/instruction-entry.ts +++ b/packages/core/src/session/instruction-entry.ts @@ -13,9 +13,59 @@ export const Key = InstructionEntry.Key export type Key = typeof Key.Type export const Info = InstructionEntry.Info export type Info = typeof Info.Type +export const Snapshot = InstructionEntry.Snapshot +export type Snapshot = typeof Snapshot.Type export const MaxValueBytes = InstructionEntry.MaxValueBytes export const ValueTooLargeError = InstructionEntry.ValueTooLargeError +type DatabaseService = Database.Interface["db"] +const InsertBatchSize = 10 + +export const snapshot = Effect.fn("InstructionEntry.snapshot")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, +) { + return yield* db + .select({ + key: InstructionEntryTable.key, + value: InstructionEntryTable.value, + removed: InstructionEntryTable.removed, + }) + .from(InstructionEntryTable) + .where(eq(InstructionEntryTable.session_id, sessionID)) + .orderBy(asc(InstructionEntryTable.key)) + .all() + .pipe(Effect.orDie) +}) + +export const initialize = Effect.fn("InstructionEntry.initialize")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + entries: Snapshot, + created: number, +) { + const batches = Array.from({ length: Math.ceil(entries.length / InsertBatchSize) }, (_, index) => + entries.slice(index * InsertBatchSize, (index + 1) * InsertBatchSize), + ) + yield* Effect.forEach( + batches, + (batch) => + db + .insert(InstructionEntryTable) + .values( + batch.map((entry) => ({ + ...entry, + session_id: sessionID, + time_created: created, + time_updated: created, + })), + ) + .run() + .pipe(Effect.orDie), + { discard: true }, + ) +}) + export interface Interface { readonly list: (sessionID: SessionSchema.ID) => Effect.Effect> readonly put: (input: { diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index d3c5068afe..164db2d508 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -15,6 +15,7 @@ import { SessionInbox } from "./inbox.js" import { Workspace } from "../workspace.js" import { InstructionState } from "./instruction-state.js" import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js" +import { InstructionEntry } from "./instruction-entry.js" import { Slug } from "../util/slug.js" import { FSUtil } from "@opencode-ai/util/fs-util" import { Money } from "@opencode-ai/schema/money" @@ -171,6 +172,9 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .pipe(Effect.orDie) if (!stored) return yield* Effect.die(new SessionAlreadyProjected()) + if (event.data.instructionEntries) + yield* InstructionEntry.initialize(db, event.data.sessionID, event.data.instructionEntries, event.created) + let cursor = -1 while (copiedSeq !== undefined) { const rows = yield* db diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 649eb9893a..576bdd2309 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -13,6 +13,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Hash } from "@opencode-ai/util/hash" import { Bus } from "@opencode-ai/core/bus" import { EventTable } from "@opencode-ai/core/event/sql" +import { Instructions } from "@opencode-ai/core/instructions/index" import { Location } from "@opencode-ai/core/location" import { Model } from "@opencode-ai/core/model" import { Project } from "@opencode-ai/core/project" @@ -24,6 +25,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInbox } from "@opencode-ai/core/session/inbox" +import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" @@ -42,6 +44,7 @@ const it = testEffect( SessionStore.node, Session.node, SessionTransfer.node, + InstructionEntry.node, ]), [ [Bus.node, Bus.configured({ persist: true })], @@ -437,6 +440,60 @@ describe("Session.create", () => { }), ) + it.effect("inherits instruction entries when forking", () => + Effect.gen(function* () { + const session = yield* Session.Service + const entries = yield* InstructionEntry.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const parent = yield* session.create({ location }) + yield* session.prompt({ sessionID: parent.id, text: "Fork context", resume: false }) + yield* SessionInbox.promote(db, bus, parent.id, "steer") + yield* entries.put({ sessionID: parent.id, key: "deploy-target", value: "production" }) + yield* entries.put({ sessionID: parent.id, key: "retired", value: true }) + yield* entries.remove({ sessionID: parent.id, key: "retired" }) + yield* Effect.forEach( + Array.from({ length: 20 }, (_, index) => index), + (index) => entries.put({ sessionID: parent.id, key: `entry-${String(index).padStart(2, "0")}`, value: index }), + { discard: true }, + ) + + const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } }) + const inheritedList = yield* entries.list(forked.id) + const inheritedValues = yield* entries.load(forked.id).pipe(Effect.flatMap(Instructions.read)) + + expect(inheritedList).toHaveLength(21) + expect(inheritedList).toContainEqual({ key: "deploy-target", value: "production" }) + expect(inheritedValues).toContainEqual({ + key: Instructions.Key.make("api/retired"), + value: Instructions.removed, + }) + + const recorded = yield* db + .select() + .from(EventTable) + .where(eq(EventTable.aggregate_id, forked.id)) + .get() + .pipe(Effect.orDie) + if (!recorded) return yield* Effect.die(new Error("Fork event not found")) + yield* entries.put({ sessionID: parent.id, key: "deploy-target", value: "staging" }) + yield* entries.put({ sessionID: parent.id, key: "new-parent-entry", value: true }) + yield* bus.remove(forked.id) + yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run().pipe(Effect.orDie) + yield* bus.replay({ + id: recorded.id, + created: recorded.created, + aggregateID: recorded.aggregate_id, + seq: recorded.seq, + type: recorded.type, + data: recorded.data, + }) + + expect(yield* entries.list(forked.id)).toEqual(inheritedList) + expect(yield* entries.load(forked.id).pipe(Effect.flatMap(Instructions.read))).toEqual(inheritedValues) + }), + ) + it.effect("does not copy a running assistant into a fork", () => Effect.gen(function* () { const session = yield* Session.Service diff --git a/packages/schema/src/instruction-entry.ts b/packages/schema/src/instruction-entry.ts index 4d1bfdbe7a..c658a169e3 100644 --- a/packages/schema/src/instruction-entry.ts +++ b/packages/schema/src/instruction-entry.ts @@ -19,6 +19,14 @@ export const Info = Schema.Struct({ }).annotate({ identifier: "InstructionEntry.Info" }) export interface Info extends Schema.Schema.Type {} +export const Snapshot = Schema.Array( + Schema.Struct({ + ...Info.fields, + removed: Schema.Boolean, + }), +).annotate({ identifier: "InstructionEntry.Snapshot" }) +export type Snapshot = typeof Snapshot.Type + export const MaxValueBytes = 8 * 1024 export class ValueTooLargeError extends Schema.TaggedError()( diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 41d5d3050c..6db772c075 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -15,6 +15,7 @@ import { Revert } from "./session-revert.js" import { Shell as ShellSchema } from "./shell.js" import { SessionError } from "./session-error.js" import { Instruction } from "./instruction.js" +import { InstructionEntry } from "./instruction-entry.js" import { Agent } from "./agent.js" import { Skill as SkillSchema } from "./skill.js" import { Money } from "./money.js" @@ -159,6 +160,7 @@ export const Forked = Event.durable({ parentID: SessionID, boundary: SessionFork.Boundary, instructions: Instruction.Values.pipe(optional), + instructionEntries: InstructionEntry.Snapshot.pipe(optional), }, }) export type Forked = typeof Forked.Type