fix(core): inherit fork instruction entries (#44004)

This commit is contained in:
Kit Langton 2026-08-21 19:46:49 -04:00 committed by GitHub
parent c29a7c152d
commit 7c6ecaaca8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 150 additions and 11 deletions

View file

@ -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
}
}
| {

View file

@ -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

View file

@ -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)
}),

View file

@ -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<ReadonlyArray<Info>>
readonly put: (input: {

View file

@ -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

View file

@ -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

View file

@ -19,6 +19,14 @@ export const Info = Schema.Struct({
}).annotate({ identifier: "InstructionEntry.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
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<ValueTooLargeError>()(

View file

@ -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