refactor(core): simplify session context epochs (#33378)
This commit is contained in:
parent
f9f2280452
commit
c6ee511485
16 changed files with 281 additions and 728 deletions
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
|
|
@ -37,5 +37,6 @@ export const migrations = (
|
|||
import("./migration/20260611035744_credential"),
|
||||
import("./migration/20260611192811_lush_chimera"),
|
||||
import("./migration/20260612174303_project_dir_strategy"),
|
||||
import("./migration/20260622142730_simplify_session_context_epoch"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260622142730_simplify_session_context_epoch",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`agent\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`replacement_seq\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`session_context_epoch\` DROP COLUMN \`revision\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -149,11 +149,8 @@ export default {
|
|||
CREATE TABLE \`session_context_epoch\` (
|
||||
\`session_id\` text PRIMARY KEY,
|
||||
\`baseline\` text NOT NULL,
|
||||
\`agent\` text DEFAULT 'build' NOT NULL,
|
||||
\`snapshot\` 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
|
||||
);
|
||||
`)
|
||||
|
|
|
|||
|
|
@ -1,54 +1,31 @@
|
|||
export * as SessionContextEpoch from "./context-epoch"
|
||||
|
||||
import { and, eq, isNull, lt, or, sql } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import type { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { Location } from "../location"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
import { ContextSnapshotDecodeError } from "./error"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionInput } from "./input"
|
||||
import { SessionMessageID } from "./message-id"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextEpochTable, SessionTable } from "./sql"
|
||||
import { SessionContextEpochTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
class RevisionMismatch extends Error {}
|
||||
class LocationMismatch extends Error {}
|
||||
export class AgentMismatch extends Error {}
|
||||
export class AgentReplacementBlocked extends Schema.TaggedErrorClass<AgentReplacementBlocked>()(
|
||||
"SessionContextEpoch.AgentReplacementBlocked",
|
||||
{ sessionID: SessionSchema.ID, previous: AgentV2.ID, current: AgentV2.ID },
|
||||
) {}
|
||||
|
||||
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
|
||||
readonly revision: number
|
||||
}
|
||||
|
||||
export function initialize(
|
||||
db: DatabaseService,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
|
||||
return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location, agent)).pipe(
|
||||
Effect.withSpan("SessionContextEpoch.initialize"),
|
||||
)
|
||||
return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize"))
|
||||
}
|
||||
|
||||
export function prepare(
|
||||
|
|
@ -56,12 +33,8 @@ export function prepare(
|
|||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError | AgentReplacementBlocked> {
|
||||
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location, agent)).pipe(
|
||||
Effect.withSpan("SessionContextEpoch.prepare"),
|
||||
)
|
||||
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError> {
|
||||
return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare"))
|
||||
}
|
||||
|
||||
const prepareOnce = Effect.fnUntraced(function* (
|
||||
|
|
@ -69,57 +42,50 @@ const prepareOnce = Effect.fnUntraced(function* (
|
|||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
) {
|
||||
const [value, stored] = yield* Effect.all([context, find(db, sessionID)], { concurrency: "unbounded" })
|
||||
const [value, stored, compaction] = yield* Effect.all(
|
||||
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (!stored) {
|
||||
const generation = yield* SystemContext.initialize(value)
|
||||
const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
|
||||
return { baseline: generation.baseline, baselineSeq, revision: 0 }
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(
|
||||
Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })),
|
||||
)
|
||||
const replacingAgent = stored.agent !== agent
|
||||
const result =
|
||||
stored.replacement_seq === null && !replacingAgent
|
||||
? yield* SystemContext.reconcile(value, snapshot)
|
||||
: yield* SystemContext.replace(value, snapshot)
|
||||
if (result._tag === "ReplacementBlocked" && replacingAgent) {
|
||||
yield* fence(db, sessionID, agent, stored.revision)
|
||||
return yield* new AgentReplacementBlocked({ sessionID, previous: stored.agent, current: agent })
|
||||
}
|
||||
const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined
|
||||
const result = replacementSeq
|
||||
? yield* SystemContext.replace(value, snapshot)
|
||||
: yield* SystemContext.reconcile(value, snapshot)
|
||||
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
|
||||
yield* fence(db, sessionID, agent, stored.revision)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision }
|
||||
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, agent, stored.revision, replacementSeq, result.generation)
|
||||
return { baseline: result.generation.baseline, baselineSeq: replacementSeq, revision: stored.revision + 1 }
|
||||
const baselineSeq = replacementSeq ?? (yield* SessionInput.latestSeq(db, sessionID))
|
||||
yield* replace(db, sessionID, baselineSeq, result.generation)
|
||||
return { baseline: result.generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
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) },
|
||||
{ commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) },
|
||||
)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq, revision: stored.revision + 1 }
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
})
|
||||
|
||||
const initializeOnce = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
) {
|
||||
if (yield* exists(db, sessionID)) return
|
||||
const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
const baselineSeq = yield* insert(db, sessionID, location, agent, generation)
|
||||
return { baseline: generation.baseline, baselineSeq, revision: 0 }
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
})
|
||||
|
||||
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
|
|
@ -142,39 +108,6 @@ const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseServic
|
|||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const requireAgentSelection = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
agent: AgentV2.ID,
|
||||
) {
|
||||
const selected = yield* db
|
||||
.select({ agent: SessionTable.agent })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!selected || (selected.agent !== null && selected.agent !== agent)) return yield* Effect.die(new AgentMismatch())
|
||||
})
|
||||
|
||||
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,
|
||||
|
|
@ -189,155 +122,53 @@ export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
|
|||
const insert = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
location: Location.Ref,
|
||||
agent: AgentV2.ID,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
return yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const placed = yield* db
|
||||
.select({ agent: SessionTable.agent })
|
||||
.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())
|
||||
if (placed.agent !== null && placed.agent !== agent) return yield* Effect.die(new AgentMismatch())
|
||||
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
|
||||
yield* db
|
||||
.insert(SessionContextEpochTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
baseline: generation.baseline,
|
||||
agent,
|
||||
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" },
|
||||
)
|
||||
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
|
||||
yield* db
|
||||
.insert(SessionContextEpochTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return baselineSeq
|
||||
})
|
||||
|
||||
const replace = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
agent: AgentV2.ID,
|
||||
expectedRevision: number,
|
||||
baselineSeq: number,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* requireAgentSelection(db, sessionID, agent)
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({
|
||||
baseline: generation.baseline,
|
||||
agent,
|
||||
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())
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const fence = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
agent: AgentV2.ID,
|
||||
expectedRevision: number,
|
||||
) {
|
||||
const current = yield* db
|
||||
.select({ selected: SessionTable.agent, revision: SessionContextEpochTable.revision })
|
||||
.from(SessionContextEpochTable)
|
||||
.innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id))
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!current || (current.selected !== null && current.selected !== agent))
|
||||
return yield* Effect.die(new AgentMismatch())
|
||||
if (current.revision !== expectedRevision) return yield* Effect.die(new RevisionMismatch())
|
||||
})
|
||||
|
||||
export const current = Effect.fn("SessionContextEpoch.current")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
agent: AgentV2.ID,
|
||||
revision: number,
|
||||
) {
|
||||
const value = yield* db
|
||||
.select({
|
||||
agent: SessionContextEpochTable.agent,
|
||||
selected: SessionTable.agent,
|
||||
revision: SessionContextEpochTable.revision,
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.from(SessionContextEpochTable)
|
||||
.innerJoin(SessionTable, eq(SessionTable.id, SessionContextEpochTable.session_id))
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return (
|
||||
value !== undefined &&
|
||||
value.agent === agent &&
|
||||
(value.selected === null || value.selected === agent) &&
|
||||
value.revision === revision
|
||||
)
|
||||
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||
})
|
||||
|
||||
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 })
|
||||
.set({ snapshot })
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die(new RevisionMismatch())
|
||||
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ type DatabaseService = Database.Interface["db"]
|
|||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||
|
||||
const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select()
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
|
|
|
|||
|
|
@ -329,19 +329,14 @@ export const layer = Layer.effectDiscard(
|
|||
if (next) yield* applyUsage(db, sessionID, next)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.AgentSwitched, (event) => {
|
||||
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
|
||||
return db
|
||||
yield* events.project(SessionEvent.AgentSwitched, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.andThen(run(db, event)),
|
||||
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq)),
|
||||
)
|
||||
})
|
||||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||
)
|
||||
yield* events.project(SessionEvent.ModelSwitched, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* db
|
||||
|
|
@ -351,8 +346,6 @@ export const layer = Layer.effectDiscard(
|
|||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* run(db, event)
|
||||
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
|
||||
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.durable.seq)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.Prompted, (event) =>
|
||||
|
|
@ -407,7 +400,6 @@ export const layer = Layer.effectDiscard(
|
|||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.InterruptRequested, () => Effect.void)
|
||||
// TODO: Reconstruct context epoch replacement state during replay without adding replay state to every EventV2 payload.
|
||||
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||
|
|
@ -426,15 +418,9 @@ export const layer = Layer.effectDiscard(
|
|||
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
|
||||
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
|
||||
yield* events.project(SessionEvent.Compaction.Ended, (event) => {
|
||||
if (event.durable === undefined) return Effect.die("Durable Session event is missing aggregate sequence")
|
||||
if (event.durable.version === 1) return Effect.void
|
||||
const seq = event.durable.seq
|
||||
return Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq)
|
||||
})
|
||||
})
|
||||
yield* events.project(SessionEvent.Compaction.Ended, (event) =>
|
||||
event.durable?.version === 1 ? Effect.void : run(db, event),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { SessionSchema } from "../schema"
|
|||
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { SystemContext } from "../../system-context/index"
|
||||
import type { SessionContextEpoch } from "../context-epoch"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
|
||||
export type RunError =
|
||||
|
|
@ -15,7 +14,6 @@ export type RunError =
|
|||
| MessageDecodeError
|
||||
| ContextSnapshotDecodeError
|
||||
| SystemContext.InitializationBlocked
|
||||
| SessionContextEpoch.AgentReplacementBlocked
|
||||
| ToolOutputStore.Error
|
||||
|
||||
/** Runs one local continuation from already-recorded Session history. */
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
} from "@opencode-ai/llm"
|
||||
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { Database } from "../../database/database"
|
||||
|
|
@ -141,8 +141,8 @@ export const layer = Layer.effect(
|
|||
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
|
||||
|
||||
type TurnTransition =
|
||||
// Request preparation observed a concurrent Session change and must restart from durable state.
|
||||
| { readonly _tag: "RebuildPreparedTurn"; readonly promotion?: SessionInput.Delivery }
|
||||
// Automatic compaction completed; rebuild the request from compacted history.
|
||||
| { readonly _tag: "ContinueAfterCompaction" }
|
||||
// Overflow compaction completed; rebuild once through the path without overflow recovery.
|
||||
| { readonly _tag: "ContinueAfterOverflowCompaction" }
|
||||
|
||||
|
|
@ -152,20 +152,11 @@ export const layer = Layer.effect(
|
|||
}
|
||||
}
|
||||
|
||||
const rebuildPreparedTurn = (promotion?: SessionInput.Delivery) =>
|
||||
new TurnTransitionError({ _tag: "RebuildPreparedTurn", promotion })
|
||||
const continueAfterCompaction = new TurnTransitionError({ _tag: "ContinueAfterCompaction" })
|
||||
const continueAfterOverflowCompaction = new TurnTransitionError({
|
||||
_tag: "ContinueAfterOverflowCompaction",
|
||||
})
|
||||
|
||||
const retryAgentMismatch = (promotion: SessionInput.Delivery | undefined) =>
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionContextEpoch.AgentMismatch
|
||||
? Effect.die(rebuildPreparedTurn(promotion))
|
||||
: Effect.die(defect),
|
||||
)
|
||||
|
||||
const sameModel = Schema.toEquivalence(Schema.UndefinedOr(ModelV2.Ref))
|
||||
const loadSystemContext = (agent: AgentV2.Selection) =>
|
||||
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
|
||||
concurrency: "unbounded",
|
||||
|
|
@ -181,13 +172,7 @@ export const layer = Layer.effect(
|
|||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
const agent = yield* agents.select(session.agent)
|
||||
const initialized = yield* SessionContextEpoch.initialize(
|
||||
db,
|
||||
loadSystemContext(agent),
|
||||
session.id,
|
||||
session.location,
|
||||
agent.id,
|
||||
).pipe(retryAgentMismatch(promotion))
|
||||
const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||
let needsContinuation = false
|
||||
if (promotion) {
|
||||
|
|
@ -199,18 +184,7 @@ export const layer = Layer.effect(
|
|||
}
|
||||
}
|
||||
const system =
|
||||
initialized ??
|
||||
(yield* SessionContextEpoch.prepare(
|
||||
db,
|
||||
events,
|
||||
loadSystemContext(agent),
|
||||
session.id,
|
||||
session.location,
|
||||
agent.id,
|
||||
).pipe(retryAgentMismatch(undefined)))
|
||||
const current = yield* getSession(sessionID)
|
||||
if ((yield* agents.select(current.agent)).id !== agent.id || !sameModel(current.model, session.model))
|
||||
return yield* Effect.die(rebuildPreparedTurn())
|
||||
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id))
|
||||
const model = yield* models.resolve(session)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
|
|
@ -228,7 +202,7 @@ export const layer = Layer.effect(
|
|||
toolChoice: isLastStep ? "none" : undefined,
|
||||
})
|
||||
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
|
||||
return yield* Effect.die(rebuildPreparedTurn())
|
||||
return yield* Effect.die(continueAfterCompaction)
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
|
|
@ -242,8 +216,6 @@ export const layer = Layer.effect(
|
|||
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
|
||||
withPublication(publisher.publish(event, outputPaths))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision)))
|
||||
return yield* Effect.die(rebuildPreparedTurn())
|
||||
const providerStream = llm.stream(request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -352,7 +324,7 @@ export const layer = Layer.effect(
|
|||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
|
||||
yield* Effect.yieldNow
|
||||
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step)
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
@ -366,7 +338,7 @@ export const layer = Layer.effect(
|
|||
yield* Effect.yieldNow
|
||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, step)
|
||||
return yield* runTurn(sessionID, defect.transition.promotion, step)
|
||||
return yield* runTurn(sessionID, undefined, step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -170,9 +170,6 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
|
|||
.primaryKey()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
baseline: text().notNull(),
|
||||
agent: text().$type<AgentV2.ID>().notNull().default(AgentV2.defaultID),
|
||||
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
|
||||
baseline_seq: integer().notNull(),
|
||||
replacement_seq: integer(),
|
||||
revision: integer().notNull().default(0),
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue