refactor(core): simplify session context epochs (#33378)

This commit is contained in:
Kit Langton 2026-06-22 17:34:03 +02:00 committed by GitHub
commit c6ee511485
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 281 additions and 728 deletions

View file

@ -71,9 +71,9 @@ describe("DatabaseMigration", () => {
).toEqual({ name: "session_context_epoch" })
expect(
yield* db.get(
sql`SELECT name, dflt_value FROM pragma_table_info('session_context_epoch') WHERE name = 'agent'`,
sql`SELECT name FROM pragma_table_info('session_context_epoch') WHERE name IN ('agent', 'replacement_seq', 'revision')`,
),
).toEqual({ name: "agent", dflt_value: "'build'" })
).toBeUndefined()
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
expect(
yield* db.all(

View file

@ -360,12 +360,12 @@ const setupOverflowRecovery = Effect.gen(function* () {
return session
})
const userTexts = (request: LLMRequest) =>
const messageTexts = (request: LLMRequest, role: "user" | "system") =>
request.messages.flatMap((message) =>
message.role === "user"
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
: [],
message.role === role ? message.content.flatMap((content) => (content.type === "text" ? [content.text] : [])) : [],
)
const userTexts = (request: LLMRequest) => messageTexts(request, "user")
const systemTexts = (request: LLMRequest) => messageTexts(request, "system")
const replaySessionProjection = (id: SessionV2.ID) =>
Effect.gen(function* () {
@ -746,39 +746,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("does not create a source Location epoch after a concurrent Session move", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
let moved = false
systemLoadHook = Effect.suspend(() => {
if (moved) return Effect.void
moved = true
return events
.publish(SessionEvent.Moved, {
sessionID,
timestamp: DateTime.makeUnsafe(1),
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
expect(Exit.isFailure(yield* session.resume(sessionID).pipe(Effect.exit))).toBe(true)
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
expect(
yield* db
.select()
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get(),
).toBeUndefined()
expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved"))
}),
)
it.effect("reuses one durable baseline after the context producer changes", () =>
Effect.gen(function* () {
yield* setup
@ -890,7 +857,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("composes selected-agent skill guidance and replaces it after an agent switch", () =>
it.effect("updates selected-agent skill guidance after an agent switch", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@ -913,12 +880,13 @@ describe("SessionRunnerLLM", () => {
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context\n\nBuild skills"],
["Initial context\n\nReviewer skills"],
["Initial context\n\nBuild skills"],
])
expect(systemTexts(requests[1]!)).toContainEqual(expect.stringContaining("Reviewer skills"))
}),
)
it.effect("retries first-epoch preparation when the selected agent changes during observation", () =>
it.effect("keeps the sampled agent when selection changes during observation", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@ -945,88 +913,12 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context\n\nReviewer skills"],
["Initial context\n\nBuild skills"],
])
}),
)
it.effect("opens a queued activity once when the selected agent changes during observation", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
let switched = false
systemLoadHook = Effect.suspend(() => {
if (switched) return Effect.void
switched = true
return events
.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer",
})
.pipe(Effect.asVoid)
})
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Queued" }),
delivery: "queue",
resume: false,
})
requests.length = 0
response = []
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect((yield* session.context(sessionID)).filter((message) => message.type === "user")).toHaveLength(1)
}),
)
it.effect("retries an agent switch before the final provider-dispatch boundary", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
let switched = false
modelResolveHook = Effect.suspend(() => {
if (switched) return Effect.void
switched = true
return events
.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: "reviewer",
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context\n\nReviewer skills"],
])
expect(
yield* db
.select({ replacementSeq: SessionContextEpochTable.replacement_seq })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
).toEqual({ replacementSeq: null })
}),
)
it.effect("retries a model switch before the final provider-dispatch boundary", () =>
it.effect("keeps the sampled model when selection changes during model resolution", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@ -1049,145 +941,11 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
response = []
yield* session.resume(sessionID)
expect(requests.map((request) => request.model)).toEqual([replacementModel])
expect(requests.map((request) => request.model)).toEqual([model])
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([["Initial context"]])
}),
)
it.effect("fences an unchanged epoch read across an agent ABA replacement request", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
let switched = false
systemLoadHook = Effect.suspend(() => {
if (switched) return Effect.void
switched = true
return events
.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: AgentV2.ID.make("reviewer"),
})
.pipe(
Effect.andThen(
events.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(2),
agent: AgentV2.defaultID,
}),
),
Effect.asVoid,
)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
requests.length = 0
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(
yield* db
.select({ replacementSeq: SessionContextEpochTable.replacement_seq })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
).toEqual({ replacementSeq: null })
}),
)
it.effect("rejects stale agent guidance when committing an existing-epoch replacement", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: AgentV2.ID.make("reviewer"),
})
const context = (text: string) =>
Effect.succeed(
SystemContext.make({
key: systemContextKey,
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed(text),
baseline: String,
update: (_previous, current) => current,
}),
)
const location = (yield* session.get(sessionID)).location
expect(
yield* SessionContextEpoch.prepare(
db,
events,
context("Stale build context"),
sessionID,
location,
AgentV2.defaultID,
).pipe(Effect.catchDefect(Effect.succeed)),
).toBeInstanceOf(SessionContextEpoch.AgentMismatch)
expect(
yield* SessionContextEpoch.prepare(
db,
events,
context("Reviewer context"),
sessionID,
location,
AgentV2.ID.make("reviewer"),
),
).toMatchObject({ baseline: "Reviewer context" })
}),
)
it.effect("blocks a cross-agent provider turn while replacement context is unavailable", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
skillBaselines.set(AgentV2.defaultID, "Build skills")
skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills")
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
agent: AgentV2.ID.make("reviewer"),
})
systemUnavailable = true
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
requests.length = 0
const blocked = yield* session.resume(sessionID).pipe(Effect.exit)
expect(Exit.isFailure(blocked)).toBe(true)
if (Exit.isFailure(blocked))
expect(Cause.squash(blocked.cause)).toBeInstanceOf(SessionContextEpoch.AgentReplacementBlocked)
expect(requests).toHaveLength(0)
systemUnavailable = false
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context\n\nReviewer skills"],
])
}),
)
it.effect("admits removed context as a chronological System message", () =>
Effect.gen(function* () {
yield* setup
@ -1209,7 +967,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("replaces the baseline lazily after a model switch and drops prior System updates", () =>
it.effect("keeps the baseline and chronological System updates after a model switch", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@ -1235,24 +993,26 @@ describe("SessionRunnerLLM", () => {
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context"],
["Initial context"],
["Replacement context"],
["Initial context"],
])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "user", "user"])
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
"user",
"user",
"system",
"model-switched",
"user",
"system",
])
yield* replaySessionProjection(sessionID)
expect(yield* session.messages({ sessionID })).toHaveLength(5)
expect(yield* session.messages({ sessionID })).toHaveLength(6)
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false })
yield* session.resume(sessionID)
}),
)
it.effect("defers replacement while admitted context is temporarily unavailable", () =>
it.effect("preserves the baseline while context is temporarily unavailable", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@ -1279,81 +1039,12 @@ describe("SessionRunnerLLM", () => {
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context"],
["Initial context"],
["Replacement context"],
["Initial context"],
])
}),
)
it.effect("advances a pending replacement to the latest invalidation boundary", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
})
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(2),
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
})
const latest = yield* SessionInput.latestSeq(db, sessionID)
expect(
yield* db
.select({ replacementSeq: SessionContextEpochTable.replacement_seq })
.from(SessionContextEpochTable)
.where(eq(SessionContextEpochTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
).toEqual({ replacementSeq: latest })
}),
)
it.effect("retries epoch preparation until observation-time invalidations settle", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
response = []
yield* session.resume(sessionID)
requests.length = 0
systemBaseline = "Changed context"
let invalidations = 0
systemLoadHook = Effect.suspend(() => {
if (invalidations === 4) return Effect.void
invalidations++
return events
.publish(SessionEvent.ModelSwitched, {
sessionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(invalidations),
model: { id: ModelV2.ID.make(`replacement-${invalidations}`), providerID: ProviderV2.ID.make("fake") },
})
.pipe(Effect.asVoid)
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
expect(invalidations).toBe(4)
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Changed context"])
}),
)
it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () =>
it.effect("rebuilds the baseline directly after completed compaction", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@ -1580,7 +1271,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("preserves effective System updates while compaction replacement is blocked", () =>
it.effect("preserves effective System updates while compaction rebaseline is blocked", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@ -1613,16 +1304,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"])
expect(
requests
.at(-1)
?.messages.some(
(message) =>
message.role === "system" &&
message.content[0]?.type === "text" &&
message.content[0].text === "Changed context",
),
).toBe(true)
expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
}),
)
@ -1821,8 +1503,9 @@ describe("SessionRunnerLLM", () => {
expect(requests.map((request) => request.model)).toEqual([model, replacementModel])
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context"],
["Replacement context"],
["Initial context"],
])
expect(systemTexts(requests[1]!)).toContain("Replacement context")
}),
)