mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-25 04:02:33 +00:00
fix(core): inherit fork instruction entries (#44004)
This commit is contained in:
parent
c29a7c152d
commit
7c6ecaaca8
8 changed files with 150 additions and 11 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>()(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue