fix(core): harden v2 session context epochs

This commit is contained in:
Kit Langton 2026-06-04 14:49:05 -04:00
commit 03974ba0e6
33 changed files with 340 additions and 6461 deletions

View file

@ -32,9 +32,6 @@ export const migrations = (
import("./migration/20260603141458_session_input_inbox"),
import("./migration/20260603160727_jittery_ezekiel_stane"),
import("./migration/20260604172448_event_sourced_session_input"),
import("./migration/20260604180746_add_session_context_epoch"),
import("./migration/20260604181329_add_session_context_updates"),
import("./migration/20260604181706_add_session_context_replacement"),
import("./migration/20260604181807_add_session_context_replacement_sequence"),
import("./migration/20260604184448_add_session_context_epoch"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -1,19 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260604180746_add_session_context_epoch",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_context_epoch\` (
\`session_id\` text PRIMARY KEY,
\`baseline\` text NOT NULL,
\`checkpoint\` text NOT NULL,
\`baseline_seq\` integer NOT NULL,
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -1,11 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260604181706_add_session_context_replacement",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`replacement_pending\` integer DEFAULT false NOT NULL;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -1,11 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260604181807_add_session_context_replacement_sequence",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`replacement_seq\` integer;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -2,9 +2,20 @@ import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260604181329_add_session_context_updates",
id: "20260604184448_add_session_context_epoch",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_context_epoch\` (
\`session_id\` text PRIMARY KEY,
\`baseline\` text NOT NULL,
\`checkpoint\` text NOT NULL,
\`baseline_seq\` integer NOT NULL,
\`replacement_seq\` integer,
\`revision\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_context_message\` (
\`session_id\` text NOT NULL,
@ -14,10 +25,6 @@ export default {
CONSTRAINT \`fk_session_context_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` ADD \`revision\` integer DEFAULT 0 NOT NULL;`)
yield* tx.run(
`CREATE INDEX \`session_context_message_session_seq_idx\` ON \`session_context_message\` (\`session_id\`,\`seq\`);`,
)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -1,6 +1,6 @@
export * as SessionContextEpoch from "./context-epoch"
import { and, eq, sql } from "drizzle-orm"
import { and, eq, isNull, lt, sql } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import type { Database } from "../database/database"
import { EventV2 } from "../event"
@ -24,28 +24,33 @@ export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* (
const stored = yield* find(db, sessionID)
if (!stored) {
const initialized = SystemContext.initialize(snapshot)
yield* events.publish(SessionEvent.ContextInitialized, {
const event = yield* events.publish(SessionEvent.ContextInitialized, {
sessionID,
timestamp: yield* DateTime.now,
baseline: initialized.baseline,
checkpoint: initialized.checkpoint,
})
return initialized.baseline
if (event.seq === undefined) return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
return { baseline: initialized.baseline, baselineSeq: event.seq }
}
if (stored.replacement_pending) {
if (stored.replacement_seq !== null) {
if (SystemContext.replacementBlocked(snapshot, stored.checkpoint))
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
const initialized = SystemContext.initialize(snapshot)
yield* events.publish(SessionEvent.ContextReplaced, {
const event = yield* events.publish(SessionEvent.ContextReplaced, {
sessionID,
timestamp: yield* DateTime.now,
expectedRevision: stored.revision,
baseline: initialized.baseline,
checkpoint: initialized.checkpoint,
})
return initialized.baseline
if (event.seq === undefined) return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
return { baseline: initialized.baseline, baselineSeq: event.seq }
}
const refreshed = SystemContext.refresh(snapshot, stored.checkpoint)
if (sameCheckpoint(refreshed.checkpoint, stored.checkpoint)) return stored.baseline
if (sameCheckpoint(refreshed.checkpoint, stored.checkpoint))
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
yield* events.publish(SessionEvent.ContextUpdated, {
sessionID,
timestamp: yield* DateTime.now,
@ -53,7 +58,7 @@ export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* (
parts: refreshed.changes,
checkpoint: refreshed.checkpoint,
})
return stored.baseline
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
})
export const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
@ -84,7 +89,6 @@ export const projectInitialized = Effect.fn("SessionContextEpoch.projectInitiali
baseline: event.data.baseline,
checkpoint: event.data.checkpoint,
baseline_seq: seq,
replacement_pending: false,
replacement_seq: null,
revision: 0,
})
@ -99,7 +103,9 @@ export const projectUpdated = Effect.fn("SessionContextEpoch.projectUpdated")(fu
) {
const stored = yield* find(db, event.data.sessionID)
if (!stored) return yield* Effect.die("Session context epoch is not initialized")
if (stored.replacement_pending) return yield* Effect.die("Session context epoch replacement is pending")
if (stored.baseline_seq > seq) return yield* Effect.void
if (stored.replacement_seq !== null && seq >= stored.replacement_seq)
return yield* Effect.die("Session context epoch replacement is pending")
if (stored.revision > event.data.expectedRevision) {
if (event.data.parts.length === 0) return yield* Effect.void
const projected = yield* db
@ -141,8 +147,9 @@ export const projectReplaced = Effect.fn("SessionContextEpoch.projectReplaced")(
) {
const stored = yield* find(db, event.data.sessionID)
if (!stored) return yield* Effect.die("Session context epoch is not initialized")
if (!stored.replacement_pending) {
if (stored.baseline_seq === seq && sameBaseline(stored.baseline, event.data.baseline)) return yield* Effect.void
if (stored.baseline_seq > seq) return yield* Effect.void
if (stored.baseline_seq === seq && sameBaseline(stored.baseline, event.data.baseline)) return yield* Effect.void
if (stored.replacement_seq === null) {
return yield* Effect.die("Session context epoch replacement was not requested")
}
const updated = yield* db
@ -151,7 +158,6 @@ export const projectReplaced = Effect.fn("SessionContextEpoch.projectReplaced")(
baseline: event.data.baseline,
checkpoint: event.data.checkpoint,
baseline_seq: seq,
replacement_pending: false,
replacement_seq: null,
revision: event.data.expectedRevision + 1,
})
@ -173,12 +179,16 @@ export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacem
sessionID: SessionSchema.ID,
seq: number,
) {
const stored = yield* find(db, sessionID)
if (!stored || stored.baseline_seq >= seq || stored.replacement_seq === seq) return yield* Effect.void
return yield* db
.update(SessionContextEpochTable)
.set({ replacement_pending: true, replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` })
.where(eq(SessionContextEpochTable.session_id, sessionID))
.set({ replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` })
.where(
and(
eq(SessionContextEpochTable.session_id, sessionID),
isNull(SessionContextEpochTable.replacement_seq),
lt(SessionContextEpochTable.baseline_seq, seq),
),
)
.run()
.pipe(Effect.orDie)
})

View file

@ -4,7 +4,7 @@ import { Database } from "../database/database"
import { MessageDecodeError } from "./error"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionContextEpochTable, SessionContextMessageTable, SessionMessageTable } from "./sql"
import { SessionContextMessageTable, SessionMessageTable } from "./sql"
import type { SystemContext } from "../system-context"
type DatabaseService = Database.Interface["db"]
@ -65,24 +65,14 @@ export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseServ
export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
baselineSeq: number,
) {
const compaction = yield* latestCompaction(db, sessionID)
const messages = yield* messageRows(db, sessionID, compaction)
const epoch = yield* db
.select({ baselineSeq: SessionContextEpochTable.baseline_seq })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
const updates = yield* db
.select()
.from(SessionContextMessageTable)
.where(
and(
eq(SessionContextMessageTable.session_id, sessionID),
epoch ? gt(SessionContextMessageTable.seq, epoch.baselineSeq) : undefined,
),
)
.where(and(eq(SessionContextMessageTable.session_id, sessionID), gt(SessionContextMessageTable.seq, baselineSeq)))
.orderBy(asc(SessionContextMessageTable.seq))
.all()
.pipe(Effect.orDie)

View file

@ -98,8 +98,11 @@ export const layer = Layer.effect(
const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
return yield* store.context(sessionID)
})
const getRunnerContext = Effect.fn("SessionRunner.getRunnerContext")(function* (sessionID: SessionSchema.ID) {
return yield* store.runnerContext(sessionID)
const getRunnerContext = Effect.fn("SessionRunner.getRunnerContext")(function* (
sessionID: SessionSchema.ID,
baselineSeq: number,
) {
return yield* store.runnerContext(sessionID, baselineSeq)
})
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
@ -132,9 +135,10 @@ 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 model = yield* models.resolve(session)
const toolFibers = yield* FiberSet.make<void, never>()
let needsContinuation = false
@ -146,12 +150,11 @@ export const layer = Layer.effect(
yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
}
}
yield* failInterruptedTools(session.id)
const system = yield* SessionContextEpoch.prepare(db, events, systemContext, session.id)
const context = yield* getRunnerContext(session.id)
const context = yield* getRunnerContext(session.id, system.baselineSeq)
const request = LLM.request({
model,
system: system.map((part) => SystemPart.make(part.text)),
system: system.baseline.map((part) => SystemPart.make(part.text)),
messages: toLLMMessages(context, model),
tools: yield* tools.definitions(),
})
@ -251,12 +254,13 @@ export const layer = Layer.effect(
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(session.id, promotion)
promotion = "steer"
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
if (!needsContinuation) break

View file

@ -10,7 +10,6 @@ import {
import { SessionMessage } from "../message"
import type { FileAttachment } from "../prompt"
import { SessionContext } from "../context"
import { SystemContext } from "../../system-context"
const media = (file: FileAttachment): ContentPart => ({
type: "media",
@ -134,7 +133,7 @@ function toLLMMessage(message: SessionContext.RunnerMessage, model: Model): Mess
}),
]
case "system-context":
return [Message.system(SystemContext.render(message.parts))]
return [Message.system(message.parts.map((part) => ({ type: "text", text: part.text })))]
}
}

View file

@ -171,7 +171,6 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
baseline: text({ mode: "json" }).notNull().$type<ReadonlyArray<SystemContext.Part>>(),
checkpoint: text({ mode: "json" }).notNull().$type<SystemContext.Checkpoint>(),
baseline_seq: integer().notNull(),
replacement_pending: integer({ mode: "boolean" }).notNull().default(false),
replacement_seq: integer(),
revision: integer().notNull().default(0),
})
@ -186,8 +185,5 @@ export const SessionContextMessageTable = sqliteTable(
seq: integer().notNull(),
parts: text({ mode: "json" }).notNull().$type<ReadonlyArray<SystemContext.Part>>(),
},
(table) => [
primaryKey({ columns: [table.session_id, table.seq] }),
index("session_context_message_session_seq_idx").on(table.session_id, table.seq),
],
(table) => [primaryKey({ columns: [table.session_id, table.seq] })],
)

View file

@ -15,6 +15,7 @@ export interface Interface {
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
readonly runnerContext: (
sessionID: SessionSchema.ID,
baselineSeq: number,
) => Effect.Effect<SessionContext.RunnerMessage[], MessageDecodeError>
readonly message: (
messageID: SessionMessage.ID,
@ -37,8 +38,8 @@ 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) {
return yield* SessionContext.loadForRunner(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

View file

@ -54,7 +54,7 @@ export const PartSchema = Schema.Struct({
text: Schema.String,
})
export const PartsSchema = Schema.Array(PartSchema)
export const CheckpointSchema = Schema.Record(Schema.String, Schema.String)
export const CheckpointSchema = Schema.Record(Key, Schema.String)
export type Checkpoint = Readonly<Record<string, string>>
@ -111,6 +111,7 @@ export function initialize(snapshot: Snapshot): Initialized {
}
export function refresh(snapshot: Snapshot, previous: Checkpoint): Refreshed {
const keys = new Set(snapshot.entries.map((entry) => entry.key))
return {
changes: [
...snapshot.entries.flatMap((entry) =>
@ -119,18 +120,15 @@ export function refresh(snapshot: Snapshot, previous: Checkpoint): Refreshed {
: [],
),
...Object.keys(previous).flatMap((key) =>
snapshot.entries.some((entry) => entry.key === key)
? []
: [{ key: Key.make(key), text: `System context component removed: ${key}` }],
keys.has(Key.make(key)) ? [] : [{ key: Key.make(key), text: `System context component removed: ${key}` }],
),
],
checkpoint: nextCheckpoint(snapshot, previous),
}
}
export function render(parts: ReadonlyArray<Part>) {
return parts.map((part) => part.text).join("\n\n")
}
export const replacementBlocked = (snapshot: Snapshot, previous: Checkpoint) =>
snapshot.entries.some((entry) => entry._tag === "Unavailable" && getCheckpoint(previous, entry.key) !== undefined)
function nextCheckpoint(snapshot: Snapshot, previous: Checkpoint) {
return Object.fromEntries(