fix(core): keep the instruction baseline on committed revert (#54410)

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
Rich Rauenzahn
2026-10-19 12:29:10 -07:00
committed by GitHub
Unverified
parent f36cda5bc8
commit a5bf171649
5 changed files with 43 additions and 7 deletions
+11 -3
View File
@@ -1,6 +1,6 @@
export * as InstructionState from "./instruction-state.js"
import { eq, inArray, sql } from "drizzle-orm"
import { and, eq, gte, inArray, sql } from "drizzle-orm"
import { Effect, Option, Schema } from "effect"
import type { Database } from "../database/database.js"
import type { Bus } from "../bus.js"
@@ -153,10 +153,18 @@ export const advanceEpoch = Effect.fn("InstructionState.advanceEpoch")(function*
.pipe(Effect.orDie)
})
export const reset = Effect.fn("InstructionState.reset")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
/**
* Clears the fold only when a committed revert removed an update or the epoch start. Otherwise the
* fold still matches the surviving history, and keeping its baseline keeps the prompt cache.
*/
export const rewind = Effect.fn("InstructionState.rewind")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
boundary: number,
) {
yield* db
.delete(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, sessionID))
.where(and(eq(InstructionStateTable.session_id, sessionID), gte(InstructionStateTable.through_seq, boundary)))
.run()
.pipe(Effect.orDie)
})
+1 -1
View File
@@ -806,7 +806,7 @@ const layer = Layer.effectDiscard(
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* InstructionState.reset(db, event.data.sessionID)
yield* InstructionState.rewind(db, event.data.sessionID, boundary.seq)
}),
)
yield* bus.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed, SessionEvent.UsageRecorded]).pipe(
+2 -2
View File
@@ -202,8 +202,8 @@ describe("SessionProjector", () => {
tokens_cache_read: 3,
tokens_cache_write: 1,
})
// A committed revert resets the fold cache so the next boundary establishes a new epoch.
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
// No instruction update followed the boundary, so the fold survives.
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toMatchObject({ through_seq: 0 })
}),
)
+28
View File
@@ -1739,6 +1739,34 @@ describe("SessionRunnerLLM", () => {
expect(yield* s.messages).toHaveLength(3)
})
scenario("keeps the system prompt stable across a committed revert", function* (s) {
yield* s.runPrompt("First")
s.systemBaseline = "Changed context"
yield* s.runPrompt("Second")
const third = yield* s.runPrompt("Third")
const before = s.requests.at(-1)?.system.map((part) => part.text)
expect(before).toEqual([defaultSystem, fakeIdentity, "Initial context"])
yield* s.bus.publish(SessionEvent.RevertEvent.Committed, { sessionID, to: third.id })
yield* s.runPrompt("Fourth")
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual(before)
})
scenario("resyncs the instruction state when a committed revert removes an update", function* (s) {
yield* s.runPrompt("First")
s.systemBaseline = "Changed context"
const second = yield* s.runPrompt("Second")
s.systemBaseline = "Latest context"
yield* s.runPrompt("Third")
// The "Latest context" update is delivered with "Third", after the boundary.
yield* s.bus.publish(SessionEvent.RevertEvent.Committed, { sessionID, to: second.id })
yield* s.runPrompt("Fourth")
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, fakeIdentity, "Latest context"])
})
scenario("uses the selected model family prompt when the agent does not override it", function* (s) {
s.currentModel = LanguageModel.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route })
yield* s.admit("First")
+1 -1
View File
@@ -93,7 +93,7 @@ Instruction sync persists content-addressed values and may freeze rendered chron
Before each Physical Attempt that reaches model execution, the runner reads every source concurrently exactly once, hashes encoded values, and admits one delta atomically with its new blobs before input delivery. The initial delta must be complete; it carries no update text. An unavailable source blocks only that initial delta and otherwise silently retains the stored value. Request assembly renders the epoch baseline from stored values. Later changes render once at admission, freeze optional `text` in the durable event, and project that text as a chronological System message; clients display changed keys rather than privileged prose.
An instruction epoch spans completed compactions. `session.compaction.ended` moves the epoch start to its exact sequence, making current values initial, without reading sources or authoring an instruction event. Session movement retains state so destination changes become chronological updates; committed revert clears state so the next boundary establishes a fresh baseline. A fork copies messages only through its selected boundary but adopts the parent's newest instruction values as its baseline. Model selection affects request assembly but is not itself an instruction source.
An instruction epoch spans completed compactions. `session.compaction.ended` moves the epoch start to its exact sequence, making current values initial, without reading sources or authoring an instruction event. Session movement retains state so destination changes become chronological updates; committed revert retains state unless it removed an instruction update, in which case it clears state so the next boundary establishes a fresh baseline. A fork copies messages only through its selected boundary but adopts the parent's newest instruction values as its baseline. Model selection affects request assembly but is not itself an instruction source.
## Compaction Rebuilds Active History