feat(core): persist v2 session context epochs (#30789)
This commit is contained in:
parent
c47cb28781
commit
1af8dafd3e
45 changed files with 4861 additions and 521 deletions
242
packages/core/src/session/context-epoch.ts
Normal file
242
packages/core/src/session/context-epoch.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
export * as SessionContextEpoch from "./context-epoch"
|
||||
|
||||
import { and, eq, isNull, lt, or, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { Location } from "../location"
|
||||
import { SystemContext } from "../system-context"
|
||||
import { SystemContextRegistry } from "../system-context-registry"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionInput } from "./input"
|
||||
import { SessionMessageID } from "./message-id"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextEpochTable, SessionTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
class RevisionMismatch extends Error {}
|
||||
class LocationMismatch extends Error {}
|
||||
|
||||
const retryRevisionMismatch = <A, E>(attempt: () => Effect.Effect<A, E>): Effect.Effect<A, E> =>
|
||||
attempt().pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof RevisionMismatch
|
||||
? Effect.yieldNow.pipe(Effect.andThen(retryRevisionMismatch(attempt)))
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
|
||||
interface Prepared {
|
||||
readonly baseline: string
|
||||
readonly baselineSeq: number
|
||||
}
|
||||
|
||||
export function initialize(
|
||||
db: DatabaseService,
|
||||
context: SystemContextRegistry.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
|
||||
return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location)).pipe(
|
||||
Effect.withSpan("SessionContextEpoch.initialize"),
|
||||
)
|
||||
}
|
||||
|
||||
export function prepare(
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
context: SystemContextRegistry.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
): Effect.Effect<Prepared, SystemContext.InitializationBlocked> {
|
||||
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location)).pipe(
|
||||
Effect.withSpan("SessionContextEpoch.prepare"),
|
||||
)
|
||||
}
|
||||
|
||||
const prepareOnce = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
context: SystemContextRegistry.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
) {
|
||||
const [value, stored] = yield* Effect.all([context.load(), find(db, sessionID)], { concurrency: "unbounded" })
|
||||
if (!stored) {
|
||||
const generation = yield* SystemContext.initialize(value)
|
||||
const baselineSeq = yield* insert(db, sessionID, location, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(Effect.orDie)
|
||||
const result =
|
||||
stored.replacement_seq === null
|
||||
? yield* SystemContext.reconcile(value, snapshot)
|
||||
: yield* SystemContext.replace(value, snapshot)
|
||||
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked")
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
if (result._tag === "ReplacementReady") {
|
||||
const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID))
|
||||
yield* replace(db, sessionID, stored.revision, replacementSeq, result.generation)
|
||||
return { baseline: result.generation.baseline, baselineSeq: replacementSeq }
|
||||
}
|
||||
|
||||
yield* events.publish(
|
||||
SessionEvent.ContextUpdated,
|
||||
{ sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text },
|
||||
{ commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) },
|
||||
)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
})
|
||||
|
||||
const initializeOnce = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
context: SystemContextRegistry.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
) {
|
||||
if (yield* exists(db, sessionID)) return
|
||||
const generation = yield* context.load().pipe(Effect.flatMap(SystemContext.initialize))
|
||||
const baselineSeq = yield* insert(db, sessionID, location, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
})
|
||||
|
||||
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return (
|
||||
(yield* db
|
||||
.select({ sessionID: SessionContextEpochTable.session_id })
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select()
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
seq: number,
|
||||
) {
|
||||
return yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({ replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionContextEpochTable.session_id, sessionID),
|
||||
lt(SessionContextEpochTable.baseline_seq, seq),
|
||||
or(isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.replacement_seq, seq)),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
yield* db.delete(SessionContextEpochTable).where(eq(SessionContextEpochTable.session_id, sessionID)).run().pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const insert = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
return yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const placed = yield* db
|
||||
.select({ sessionID: SessionTable.id })
|
||||
.from(SessionTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionTable.id, sessionID),
|
||||
eq(SessionTable.directory, location.directory),
|
||||
location.workspaceID === undefined
|
||||
? isNull(SessionTable.workspace_id)
|
||||
: eq(SessionTable.workspace_id, location.workspaceID),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!placed) return yield* Effect.die(new LocationMismatch())
|
||||
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
|
||||
yield* db
|
||||
.insert(SessionContextEpochTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
revision: 0,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))),
|
||||
)
|
||||
return baselineSeq
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const replace = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
expectedRevision: number,
|
||||
baselineSeq: number,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
replacement_seq: null,
|
||||
revision: expectedRevision + 1,
|
||||
})
|
||||
.where(
|
||||
and(eq(SessionContextEpochTable.session_id, sessionID), eq(SessionContextEpochTable.revision, expectedRevision)),
|
||||
)
|
||||
.returning({ revision: SessionContextEpochTable.revision })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new RevisionMismatch())
|
||||
})
|
||||
|
||||
const advance = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
expectedRevision: number,
|
||||
snapshot: SystemContext.Snapshot,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({ snapshot, revision: expectedRevision + 1 })
|
||||
.where(
|
||||
and(
|
||||
eq(SessionContextEpochTable.session_id, sessionID),
|
||||
eq(SessionContextEpochTable.revision, expectedRevision),
|
||||
isNull(SessionContextEpochTable.replacement_seq),
|
||||
),
|
||||
)
|
||||
.returning({ revision: SessionContextEpochTable.revision })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new RevisionMismatch())
|
||||
})
|
||||
|
|
@ -1,47 +1,92 @@
|
|||
import { and, asc, desc, eq, gt, gte, or } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gt, gte, ne, or } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionMessageTable } from "./sql"
|
||||
import { SessionContextEpochTable, SessionMessageTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
|
||||
export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const compaction = yield* db
|
||||
.select()
|
||||
const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const rows = yield* db
|
||||
})
|
||||
|
||||
const messageRows = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
compaction: { readonly seq: number } | undefined,
|
||||
baselineSeq?: number,
|
||||
) {
|
||||
return yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
compaction ? or(gte(SessionMessageTable.seq, compaction.seq)) : undefined,
|
||||
compaction
|
||||
? or(
|
||||
gte(SessionMessageTable.seq, compaction.seq),
|
||||
baselineSeq === undefined
|
||||
? undefined
|
||||
: and(eq(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)),
|
||||
)
|
||||
: undefined,
|
||||
baselineSeq === undefined
|
||||
? undefined
|
||||
: or(ne(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return yield* Effect.forEach(rows, (row) =>
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
messageID: SessionMessage.ID.make(row.id),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const [epoch, compaction] = yield* Effect.all(
|
||||
[
|
||||
db
|
||||
.select({ baselineSeq: SessionContextEpochTable.baseline_seq })
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
latestCompaction(db, sessionID),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow)
|
||||
})
|
||||
|
||||
export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
) {
|
||||
return yield* Effect.forEach(
|
||||
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq),
|
||||
decodeMessageRow,
|
||||
)
|
||||
})
|
||||
|
||||
export * as SessionContext from "./context"
|
||||
|
|
|
|||
|
|
@ -119,6 +119,17 @@ export namespace PromptLifecycle {
|
|||
export type Promoted = typeof Promoted.Type
|
||||
}
|
||||
|
||||
export const ContextUpdated = EventV2.define({
|
||||
type: "session.next.context.updated",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
messageID: SessionMessageID.ID,
|
||||
text: Schema.String,
|
||||
},
|
||||
})
|
||||
export type ContextUpdated = typeof ContextUpdated.Type
|
||||
|
||||
export const Synthetic = EventV2.define({
|
||||
type: "session.next.synthetic",
|
||||
...options,
|
||||
|
|
@ -444,6 +455,7 @@ const DurableDefinitions = [
|
|||
Prompted,
|
||||
PromptLifecycle.Admitted,
|
||||
PromptLifecycle.Promoted,
|
||||
ContextUpdated,
|
||||
Synthetic,
|
||||
Shell.Started,
|
||||
Shell.Ended,
|
||||
|
|
|
|||
|
|
@ -159,6 +159,15 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
},
|
||||
"session.next.prompt.admitted": () => Effect.void,
|
||||
"session.next.prompt.promoted": () => Effect.void,
|
||||
"session.next.context.updated": (event) =>
|
||||
adapter.appendMessage(
|
||||
new SessionMessage.System({
|
||||
id: event.data.messageID,
|
||||
type: "system",
|
||||
text: event.data.text,
|
||||
time: { created: event.data.timestamp },
|
||||
}),
|
||||
),
|
||||
"session.next.synthetic": (event) => {
|
||||
return adapter.appendMessage(
|
||||
new SessionMessage.Synthetic({
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ export class Synthetic extends Schema.Class<Synthetic>("Session.Message.Syntheti
|
|||
type: Schema.Literal("synthetic"),
|
||||
}) {}
|
||||
|
||||
export class System extends Schema.Class<System>("Session.Message.System")({
|
||||
...Base,
|
||||
type: Schema.Literal("system"),
|
||||
text: SessionEvent.ContextUpdated.data.fields.text,
|
||||
}) {}
|
||||
|
||||
export class Shell extends Schema.Class<Shell>("Session.Message.Shell")({
|
||||
...Base,
|
||||
type: Schema.Literal("shell"),
|
||||
|
|
@ -170,7 +176,16 @@ export class Compaction extends Schema.Class<Compaction>("Session.Message.Compac
|
|||
...Base,
|
||||
}) {}
|
||||
|
||||
export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, Shell, Assistant, Compaction])
|
||||
export const Message = Schema.Union([
|
||||
AgentSwitched,
|
||||
ModelSwitched,
|
||||
User,
|
||||
Synthetic,
|
||||
System,
|
||||
Shell,
|
||||
Assistant,
|
||||
Compaction,
|
||||
])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Session.Message" })
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { SessionMessage } from "./message"
|
|||
import { SessionMessageUpdater } from "./message-updater"
|
||||
import { SessionInput } from "./input"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { SessionContextEpoch } from "./context-epoch"
|
||||
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import type { DeepMutable } from "../schema"
|
||||
|
||||
|
|
@ -259,17 +260,20 @@ export const layer = Layer.effectDiscard(
|
|||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* events.project(SessionEvent.Moved, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
directory: event.data.location.directory,
|
||||
path: event.data.subdirectory,
|
||||
workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
|
||||
time_updated: DateTime.toEpochMillis(event.data.timestamp),
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
Effect.gen(function* () {
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
directory: event.data.location.directory,
|
||||
path: event.data.subdirectory,
|
||||
workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
|
||||
time_updated: DateTime.toEpochMillis(event.data.timestamp),
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* SessionContextEpoch.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionV1.Event.Deleted, (event) =>
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||
|
|
@ -352,12 +356,18 @@ export const layer = Layer.effectDiscard(
|
|||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||
)
|
||||
yield* events.project(SessionEvent.ModelSwitched, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||
Effect.gen(function* () {
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* run(db, event)
|
||||
if (event.seq === undefined)
|
||||
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Prompted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -413,6 +423,12 @@ export const layer = Layer.effectDiscard(
|
|||
)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.ContextUpdated, (event) => {
|
||||
if (!event.replay || event.seq === undefined) return run(db, event)
|
||||
return run(db, event).pipe(
|
||||
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
|
||||
)
|
||||
})
|
||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
|
||||
|
|
@ -432,7 +448,12 @@ export const layer = Layer.effectDiscard(
|
|||
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Compaction.Delta, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Compaction.Ended, (event) => {
|
||||
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||
return run(db, event).pipe(
|
||||
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Context, Effect, Schema } from "effect"
|
|||
import { SessionSchema } from "../schema"
|
||||
import type { MessageDecodeError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { SystemContext } from "../../system-context"
|
||||
|
||||
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
|
||||
"SessionRunner.StepLimitExceededError",
|
||||
|
|
@ -14,7 +15,12 @@ export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExc
|
|||
},
|
||||
) {}
|
||||
|
||||
export type RunError = LLMError | SessionRunnerModel.Error | MessageDecodeError | StepLimitExceededError
|
||||
export type RunError =
|
||||
| LLMError
|
||||
| SessionRunnerModel.Error
|
||||
| MessageDecodeError
|
||||
| StepLimitExceededError
|
||||
| SystemContext.InitializationBlocked
|
||||
|
||||
/** Runs one local continuation from already-recorded Session history. */
|
||||
export interface Interface {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { LLM, LLMClient, LLMError, LLMEvent } from "@opencode-ai/llm"
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, SystemPart } from "@opencode-ai/llm"
|
||||
import { Cause, DateTime, Effect, FiberSet, Layer, Semaphore, Stream } from "effect"
|
||||
import { EventV2 } from "../../event"
|
||||
import { ModelV2 } from "../../model"
|
||||
|
|
@ -14,6 +14,8 @@ import { SessionRunnerModel } from "./model"
|
|||
import { Database } from "../../database/database"
|
||||
import { SessionInput } from "../input"
|
||||
import { QuestionV2 } from "../../question"
|
||||
import { SystemContextRegistry } from "../../system-context-registry"
|
||||
import { SessionContextEpoch } from "../context-epoch"
|
||||
|
||||
/**
|
||||
* Runs one durable coding-agent Session until it settles.
|
||||
|
|
@ -34,8 +36,8 @@ import { QuestionV2 } from "../../question"
|
|||
* - [x] Resolve the selected model through the location-scoped runner environment.
|
||||
* - [ ] Load the selected agent and effective permissions.
|
||||
* - [ ] Build provider/model-specific base instructions and environment facts.
|
||||
* - [ ] Load configured project instructions such as `AGENTS.md`, remote instructions, and
|
||||
* nearby nested instructions discovered while files are read.
|
||||
* - [x] Load global and upward project `AGENTS.md` instructions.
|
||||
* - [ ] Load configured and remote instructions plus nearby nested instructions discovered while files are read.
|
||||
* - [ ] List available skills in the system prompt and expose a tool for loading skill bodies.
|
||||
* - [ ] Resolve referenced files, directories, agents, repositories, MCP resources, and media.
|
||||
* - [ ] Apply steering reminders, plugin transforms, and structured-output policy.
|
||||
|
|
@ -85,6 +87,7 @@ export const layer = Layer.effect(
|
|||
const tools = yield* ToolRegistry.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const systemContext = yield* SystemContextRegistry.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
|
|
@ -95,7 +98,6 @@ export const layer = Layer.effect(
|
|||
const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
|
||||
return yield* store.context(sessionID)
|
||||
})
|
||||
|
||||
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
|
|
@ -126,9 +128,11 @@ export const layer = Layer.effect(
|
|||
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
|
||||
|
||||
const runTurn = Effect.fn("SessionRunner.runTurn")(function* (
|
||||
session: SessionSchema.Info,
|
||||
sessionID: SessionSchema.ID,
|
||||
promotion: "steer" | "queue" | undefined,
|
||||
) {
|
||||
const session = yield* getSession(sessionID)
|
||||
const initialized = yield* SessionContextEpoch.initialize(db, systemContext, session.id, session.location)
|
||||
const model = yield* models.resolve(session)
|
||||
const toolFibers = yield* FiberSet.make<void, never>()
|
||||
let needsContinuation = false
|
||||
|
|
@ -140,9 +144,14 @@ export const layer = Layer.effect(
|
|||
yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
}
|
||||
}
|
||||
yield* failInterruptedTools(session.id)
|
||||
const context = yield* getContext(session.id)
|
||||
const request = LLM.request({ model, messages: toLLMMessages(context, model), tools: yield* tools.definitions() })
|
||||
const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id, session.location))
|
||||
const context = yield* store.runnerContext(session.id, system.baselineSeq)
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: system.baseline.length > 0 ? [SystemPart.make(system.baseline)] : [],
|
||||
messages: toLLMMessages(context, model),
|
||||
tools: yield* tools.definitions(),
|
||||
})
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
sessionID: session.id,
|
||||
agent: session.agent ?? "build",
|
||||
|
|
@ -235,16 +244,16 @@ export const layer = Layer.effect(
|
|||
readonly sessionID: SessionSchema.ID
|
||||
readonly force?: boolean
|
||||
}) {
|
||||
const session = yield* getSession(input.sessionID)
|
||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
if (input.force !== true && !hasSteer && !hasQueue) return
|
||||
yield* failInterruptedTools(input.sessionID)
|
||||
let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||
let openActivity = input.force === true || hasSteer || hasQueue
|
||||
while (openActivity) {
|
||||
let needsContinuation = true
|
||||
for (let step = 0; step < MAX_STEPS; step++) {
|
||||
needsContinuation = yield* runTurn(session, promotion)
|
||||
needsContinuation = yield* runTurn(input.sessionID, promotion)
|
||||
promotion = "steer"
|
||||
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
if (!needsContinuation) break
|
||||
|
|
|
|||
|
|
@ -111,6 +111,8 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
|||
]
|
||||
case "synthetic":
|
||||
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
|
||||
case "system":
|
||||
return [Message.system(message.text)]
|
||||
case "shell":
|
||||
return [
|
||||
Message.make({
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { SessionSchema } from "./schema"
|
|||
import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import type { SystemContext } from "../system-context"
|
||||
|
||||
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
|
||||
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
|
||||
|
|
@ -161,3 +162,15 @@ export const SessionInputTable = sqliteTable(
|
|||
uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq),
|
||||
],
|
||||
)
|
||||
|
||||
export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
|
||||
session_id: text()
|
||||
.$type<SessionSchema.ID>()
|
||||
.primaryKey()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
baseline: text().notNull(),
|
||||
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
|
||||
baseline_seq: integer().notNull(),
|
||||
replacement_seq: integer(),
|
||||
revision: integer().notNull().default(0),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import { fromRow } from "./info"
|
|||
export interface Interface {
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
|
||||
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||
readonly runnerContext: (
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
|
||||
|
|
@ -34,6 +38,9 @@ export const layer = Layer.effect(
|
|||
context: Effect.fn("SessionStore.context")(function* (sessionID) {
|
||||
return yield* SessionContext.load(db, sessionID)
|
||||
}),
|
||||
runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID, baselineSeq) {
|
||||
return yield* SessionContext.loadForRunner(db, sessionID, baselineSeq)
|
||||
}),
|
||||
message: Effect.fn("SessionStore.message")(function* (messageID) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue