Compare commits

...
23 changed files with 983 additions and 36 deletions
@@ -81,9 +81,6 @@ for (const lines of [6000, 25000]) {
await page.mouse.up()
await expect(scroll.locator(".scroll-view__viewport")).toHaveJSProperty("scrollTop", 0)
await expect(input).toBeFocused()
await page.keyboard.press("ControlOrMeta+Home")
await page.keyboard.press("ControlOrMeta+End")
await expectCaretVisible(input)
})
}
+13
View File
@@ -1044,6 +1044,19 @@ export type SessionLogOutput =
}
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.revert.prepared"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
readonly paths: ReadonlyArray<RelativePath>
}
}
)
| EventLog.Synced
export type SessionLogOperation<E = never> = (input: SessionLogInput) => Stream.Stream<SessionLogOutput, E>
@@ -851,6 +851,16 @@ export type SessionUsageRecorded = {
data: { sessionID: string; source: "title" | "compaction"; cost: MoneyUSD; tokens: TokenUsageInfo }
}
export type SessionRevertPrepared = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.revert.prepared"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; snapshot?: string; paths: Array<string> }
}
export type ModelsDevRefreshed = {
id: string
created: number
@@ -2232,6 +2242,7 @@ export type SessionEventDurable =
| SessionRevertCommitted
| SessionMessageContentUpdated
| SessionUsageRecorded
| SessionRevertPrepared
export type IntegrationInfo = {
id: string
+43
View File
@@ -282,6 +282,49 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
})
test("session.log decodes revert preparation and subsequent path deltas", async () => {
const data = [
{ sessionID: "ses_test", snapshot: "original", paths: ["first.txt"] },
{ sessionID: "ses_test", paths: ["second.txt"] },
]
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(
data
.map(
(data, seq) =>
`data: ${JSON.stringify({
id: `evt_prepared_${seq}`,
created: 1,
type: "session.revert.prepared",
durable: { aggregateID: "ses_test", seq, version: 1 },
data,
})}\n\n`,
)
.join(""),
{ headers: { "content-type": "text/event-stream" } },
),
),
),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.session.log({ sessionID: Session.ID.make("ses_test") }).pipe(Stream.runCollect)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(
result.map((event) => {
if (event.type !== "session.revert.prepared") throw new Error("Expected revert preparation")
return { snapshot: event.data.snapshot?.toString(), paths: event.data.paths.map(String) }
}),
).toEqual([
{ snapshot: "original", paths: ["first.txt"] },
{ snapshot: undefined, paths: ["second.txt"] },
])
})
test("session.log retains the typed SessionNotFoundError", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
+12 -2
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
"id": "82a127a2-4a18-4bf8-91fe-498d94f7f71f",
"prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"],
"ddl": [
{
"name": "account_state",
@@ -1300,6 +1300,16 @@
"entityType": "columns",
"table": "session_v2"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "revert_pending",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "text",
"notNull": false,
+2
View File
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260903172701_revert-recovery.js"
export const migrations = [
m00,
@@ -93,4 +94,5 @@ export const migrations = [
m43,
m44,
m45,
m46,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260903172701_revert-recovery",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`revert_pending\` text;`)
})
},
}
export default migration
+1
View File
@@ -204,6 +204,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`tokens_cache_read\` integer DEFAULT 0 NOT NULL,
\`tokens_cache_write\` integer DEFAULT 0 NOT NULL,
\`revert\` text,
\`revert_pending\` text,
\`permission\` text,
\`agent\` text,
\`model\` text,
@@ -447,6 +447,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
if (current?.status === "running") return yield* adapter.updateCompaction(failed)
yield* adapter.appendMessage(failed)
}),
"session.revert.prepared": () => Effect.void,
"session.revert.staged": () => Effect.void,
"session.revert.cleared": () => Effect.void,
"session.revert.committed": () => Effect.void,
+48 -3
View File
@@ -24,6 +24,7 @@ import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, RelativePath } from "../schema.js"
import type { SessionSchema } from "./schema.js"
import { ProjectTable } from "../project/sql.js"
import { Snapshot } from "@opencode-ai/schema/snapshot"
type DatabaseService = Database.Interface["db"]
type MessageEvent = Exclude<
@@ -33,6 +34,7 @@ type MessageEvent = Exclude<
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const decodePaths = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Array(RelativePath)))
export class SessionAlreadyProjected extends Error {}
@@ -631,7 +633,13 @@ const layer = Layer.effectDiscard(
})
yield* db
.update(SessionTable)
.set({ time_updated: event.created })
.set({
time_updated: event.created,
// New user work accepts failed preparation; synthetic notices do not.
...(event.data.item.type === "user" || event.data.item.type === "compaction"
? { revert_pending: null }
: {}),
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
@@ -696,6 +704,42 @@ const layer = Layer.effectDiscard(
}),
)
yield* bus.project(SessionEvent.Compaction.Failed, (event) => run(db, event))
yield* bus.project(SessionEvent.RevertEvent.Prepared, (event) =>
Effect.gen(function* () {
const row = yield* db
.select({
pending: SessionTable.revert_pending,
// Keep patch payloads inside SQLite; a pending record already contains all needed metadata.
snapshot: sql<
string | null
>`case when ${SessionTable.revert_pending} is null then json_extract(${SessionTable.revert}, '$.snapshot') end`,
paths: sql<string | null>`case when ${SessionTable.revert_pending} is null then (
select json_group_array(coalesce(json_extract(value, '$.file'), json_extract(value, '$.path')))
from json_each(${SessionTable.revert}, '$.files')
) end`,
})
.from(SessionTable)
.where(eq(SessionTable.id, event.data.sessionID))
.get()
.pipe(Effect.orDie)
const snapshot = row?.pending?.snapshot ?? row?.snapshot ?? event.data.snapshot
if (!snapshot) return yield* Effect.die(new Error("Revert preparation requires an original snapshot"))
return yield* db
.update(SessionTable)
.set({
revert_pending: {
snapshot: Snapshot.ID.make(snapshot),
paths: Array.from(
new Set([...(row?.pending?.paths ?? decodePaths(row?.paths ?? "[]")), ...event.data.paths]),
),
},
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.asVoid)
}),
)
yield* bus.project(SessionEvent.RevertEvent.Staged, (event) =>
Effect.gen(function* () {
const revert = event.data.revert
@@ -703,6 +747,7 @@ const layer = Layer.effectDiscard(
.update(SessionTable)
.set({
revert: { ...revert, files: revert.files ? [...revert.files] : undefined },
revert_pending: null,
time_updated: event.created,
})
.where(eq(SessionTable.id, event.data.sessionID))
@@ -713,7 +758,7 @@ const layer = Layer.effectDiscard(
yield* bus.project(SessionEvent.RevertEvent.Cleared, (event) =>
db
.update(SessionTable)
.set({ revert: null, time_updated: event.created })
.set({ revert: null, revert_pending: null, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.asVoid),
@@ -748,7 +793,7 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
yield* db
.update(SessionTable)
.set({ revert: null, time_updated: event.created })
.set({ revert: null, revert_pending: null, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
+59 -18
View File
@@ -11,7 +11,8 @@ import { SessionEvent } from "./event.js"
import { MessageNotFoundError } from "./error.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable } from "./sql.js"
import { SessionMessageTable, SessionTable } from "./sql.js"
import type { SessionStore } from "./store.js"
export { MessageNotFoundError }
@@ -28,18 +29,30 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
const instances = yield* Instance.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const next = yield* plan(database.db, { sessionID: input.session.id, messageID: input.messageID })
const pending = yield* loadPending(database.db, input.session.id)
return yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const original = input.session.revert?.snapshot
? Snapshot.ID.make(input.session.revert.snapshot)
: yield* snapshot.capture()
const next = yield* plan(database.db, { sessionID: input.session.id, messageID: input.messageID })
const recorded = pending?.snapshot ?? input.session.revert?.snapshot
const original = recorded ?? (yield* snapshot.capture())
const previous = new Set(
pending?.paths ?? (input.session.revert?.files ?? []).map((file) => RelativePath.make(file.file)),
)
const restore = new Map<RelativePath, Snapshot.ID>()
if (original) {
for (const file of input.session.revert?.files ?? []) restore.set(RelativePath.make(file.file), original)
}
if (original) for (const file of previous) restore.set(file, original)
if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree)
if (restore.size && !original)
return yield* new Snapshot.Error({
operation: "capture",
message: "Cannot restore files without an original snapshot",
})
const added = Array.from(restore.keys()).filter((file) => !previous.has(file))
if (restore.size && (!recorded || added.length))
yield* bus.publish(SessionEvent.RevertEvent.Prepared, {
sessionID: input.session.id,
snapshot: recorded ? undefined : original,
paths: added,
})
if (restore.size) yield* snapshot.restore({ files: restore })
const paths = input.files === false ? [] : Array.from(next.keys())
const files = original
@@ -60,14 +73,20 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
export const clear = Effect.fn("SessionRevert.clear")(function* (session: SessionSchema.Info) {
const instances = yield* Instance.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const pending = yield* loadPending(database.db, session.id)
if (!session.revert && !pending) return
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
if (!session.revert) return
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
const original = pending?.snapshot ?? session.revert?.snapshot
if (original)
yield* snapshot.restore({
files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])),
files: new Map(
(pending?.paths ?? (session.revert?.files ?? []).map((file) => RelativePath.make(file.file))).map(
(file) => [file, original] as const,
),
),
})
yield* bus.publish(SessionEvent.RevertEvent.Cleared, {
sessionID: session.id,
@@ -75,14 +94,36 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio
}).pipe(instances.provide(session))
})
export const commit = Effect.fn("SessionRevert.commit")(function* (bus: Bus.Interface, session: SessionSchema.Info) {
if (!session.revert) return
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
sessionID: session.id,
to: session.revert.messageID,
})
export const commit = Effect.fn("SessionRevert.commit")(function* (
bus: Bus.Interface,
store: SessionStore.Interface,
session: SessionSchema.Info,
) {
if (session.revert) {
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
sessionID: session.id,
to: session.revert.messageID,
})
return
}
if (!(yield* store.hasPendingRevert(session.id))) return
// Failed preparation has no boundary to commit. Accept the current files without
// deleting history, and retire protection before new work can change those files.
yield* bus.publish(SessionEvent.RevertEvent.Cleared, { sessionID: session.id })
})
function loadPending(db: Database.Interface["db"], sessionID: SessionSchema.ID) {
return db
.select({ pending: SessionTable.revert_pending })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(
Effect.orDie,
Effect.map((row) => row?.pending),
)
}
const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["db"], input: BoundaryInput) {
const boundary = yield* db
.select({ seq: SessionMessageTable.seq })
+2
View File
@@ -60,6 +60,8 @@ const layer = Layer.effect(
const control = pending.type === "compaction" || pending.type === "move"
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
}
// Retried admissions and explicit resumes must not overwrite a protected undo baseline.
if (yield* store.hasPendingRevert(sessionID)) return DrainResult.Complete()
yield* plugins.awaitActivation
yield* settleStaleCompactions(sessionID)
yield* settleStaleToolCalls(sessionID)
+8 -6
View File
@@ -171,7 +171,7 @@ export const make = Effect.fn("Session.make")(function* () {
),
)
// Commit a staged revert only after preparation succeeds, before admitting new work.
if (session.revert) yield* SessionRevert.commit(bus, session)
if (session.revert) yield* SessionRevert.commit(bus, store, session)
return yield* admission.admit({
id: messageID,
sessionID: session.id,
@@ -180,7 +180,7 @@ export const make = Effect.fn("Session.make")(function* () {
}).pipe(
Effect.catchTag("SessionInbox.LifecycleConflict", () => new PromptConflictError({ sessionID, messageID })),
)
if (input.resume !== false) yield* execution.wake(sessionID)
if (input.resume !== false && !(yield* store.hasPendingRevert(sessionID))) yield* execution.wake(sessionID)
return admitted
}),
),
@@ -257,7 +257,7 @@ export const make = Effect.fn("Session.make")(function* () {
input: { id?: SessionMessage.ID; delivery?: SessionInbox.Delivery },
) {
const session = yield* get(sessionID)
if (session.revert) yield* SessionRevert.commit(bus, session)
if (session.revert) yield* SessionRevert.commit(bus, store, session)
const inputID = input.id ?? SessionMessage.ID.create()
const admitted = yield* admission
.admitCompaction({
@@ -268,7 +268,7 @@ export const make = Effect.fn("Session.make")(function* () {
.pipe(
Effect.catchTag("SessionInbox.LifecycleConflict", () => new CompactionConflictError({ sessionID, inputID })),
)
yield* execution.wake(sessionID)
if (!(yield* store.hasPendingRevert(sessionID))) yield* execution.wake(sessionID)
return admitted
})
const wait = Effect.fn("Session.wait")(function* (sessionID: SessionSchema.ID) {
@@ -316,7 +316,8 @@ export const make = Effect.fn("Session.make")(function* () {
() => new SyntheticConflictError({ sessionID, inputID }),
),
)
if (input.resume !== false && !(yield* get(sessionID)).revert) yield* execution.wake(sessionID)
if (input.resume !== false && !(yield* get(sessionID)).revert && !(yield* store.hasPendingRevert(sessionID)))
yield* execution.wake(sessionID)
return admitted
}),
),
@@ -342,6 +343,7 @@ export const make = Effect.fn("Session.make")(function* () {
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
yield* SessionRevert.clear(session).pipe(
Effect.provideService(Instance.Service, instances),
Effect.provideService(Database.Service, database),
Effect.provideService(Bus.Service, bus),
)
return yield* execution.wake(sessionID)
@@ -349,7 +351,7 @@ export const make = Effect.fn("Session.make")(function* () {
const commit = Effect.fn("Session.revert.commit")(function* (sessionID: SessionSchema.ID) {
const session = yield* get(sessionID)
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
return yield* SessionRevert.commit(bus, session)
return yield* SessionRevert.commit(bus, store, session)
})
const revert = { stage, clear, commit }
const operations = {
+6
View File
@@ -14,6 +14,8 @@ import type { Instruction } from "@opencode-ai/schema/instruction"
import type { Session } from "@opencode-ai/schema/session"
import type { CompactionPayload, MovePayload, SyntheticPayload, UserPayload } from "@opencode-ai/schema/session-inbox"
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
import type { RelativePath } from "@opencode-ai/schema/schema"
import type { Snapshot } from "@opencode-ai/schema/snapshot"
import type { Schema } from "effect"
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
@@ -49,6 +51,10 @@ export const SessionTable = sqliteTable(
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<Session.Revert | RevertV1>(),
revert_pending: text({ mode: "json" }).$type<{
readonly snapshot: Snapshot.ID
readonly paths: readonly RelativePath[]
}>(),
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
agent: text(),
model: text({ mode: "json" }).$type<{
+13
View File
@@ -51,6 +51,8 @@ export type MessagesInput = {
export interface Interface {
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
/** Recovery preparation is deliberately absent from public Session.Info. */
readonly hasPendingRevert: (sessionID: Session.ID) => Effect.Effect<boolean>
readonly list: (input?: ListInput) => Effect.Effect<Session.Info[]>
readonly messages: (input: MessagesInput) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
@@ -95,6 +97,17 @@ const layer = Layer.effect(
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
return row ? fromRow(row) : undefined
}),
hasPendingRevert: Effect.fn("SessionStore.hasPendingRevert")((sessionID) =>
db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(and(eq(SessionTable.id, sessionID), isNotNull(SessionTable.revert_pending)))
.get()
.pipe(
Effect.orDie,
Effect.map((row) => row !== undefined),
),
),
list: Effect.fn("SessionStore.list")(function* (input = {}) {
const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
+112 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { and, eq } from "drizzle-orm"
import { and, asc, eq } from "drizzle-orm"
import { Cause, Context, DateTime, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Event } from "@opencode-ai/schema/event"
@@ -705,6 +705,117 @@ describe("Session-owned handles", () => {
}),
)
for (const operation of ["commit", "prompt", "compact"] as const) {
it.live(`${operation} accepts failed revert preparation without changing files or deleting history`, () =>
Effect.gen(function* () {
const fixture = yield* setup({
snapshot: () =>
Layer.mock(Snapshot.Service, {
restore: () => Effect.die("Committed preparation must not restore files"),
}),
})
const handle = fixture.sessions.forSession(sessionID)
const boundary = yield* handle.synthetic({ text: "Retain this history", resume: false })
yield* SessionInbox.promote(fixture.db, fixture.bus, sessionID, "steer")
yield* fixture.bus.publish(SessionEvent.RevertEvent.Prepared, {
sessionID,
snapshot: Snapshot.ID.make("original"),
paths: [RelativePath.make("file.txt")],
})
const acquisitions = fixture.locations.length
if (operation === "commit") yield* handle.revert.commit()
if (operation === "prompt") yield* handle.prompt({ text: "Continue from these files", resume: false })
if (operation === "compact") yield* handle.compact({})
expect(fixture.locations).toHaveLength(acquisitions + (operation === "prompt" ? 1 : 0))
expect(yield* fixture.store.hasPendingRevert(sessionID)).toBe(false)
expect((yield* handle.get()).revert).toBeUndefined()
expect(yield* fixture.store.context(sessionID)).toMatchObject([{ id: boundary.id }])
const events = yield* fixture.db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
expect(events.at(-1)?.type).toBe(
operation === "commit" ? "session.revert.cleared.1" : "session.inbox.enqueued.1",
)
yield* handle.revert.clear()
}),
)
}
it.live("does not wake pending recovery when retrying an already admitted prompt", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
const input = { id: SessionMessage.ID.create(), text: "Existing input" }
yield* handle.prompt({ ...input, resume: false })
yield* fixture.bus.publish(SessionEvent.RevertEvent.Prepared, {
sessionID,
snapshot: Snapshot.ID.make("original"),
paths: [RelativePath.make("file.txt")],
})
yield* handle.prompt(input)
expect(yield* fixture.store.hasPendingRevert(sessionID)).toBe(true)
expect(fixture.wakes).toEqual([])
}),
)
it.live("does not wake pending recovery when retrying an already admitted compaction", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
const input = { id: SessionMessage.ID.create() }
yield* handle.compact(input)
const wakes = fixture.wakes.length
yield* fixture.bus.publish(SessionEvent.RevertEvent.Prepared, {
sessionID,
snapshot: Snapshot.ID.make("original"),
paths: [RelativePath.make("file.txt")],
})
yield* handle.compact(input)
expect(yield* fixture.store.hasPendingRevert(sessionID)).toBe(true)
expect(fixture.wakes).toHaveLength(wakes)
}),
)
it.live("retains failed revert preparation without waking for synthetic notices", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
yield* fixture.bus.publish(SessionEvent.RevertEvent.Prepared, {
sessionID,
snapshot: Snapshot.ID.make("original"),
paths: [RelativePath.make("file.txt")],
})
yield* handle.synthetic({ text: "Background notice" })
expect(yield* fixture.store.hasPendingRevert(sessionID)).toBe(true)
expect(fixture.wakes).toEqual([])
}),
)
it.live("retains failed revert preparation when new prompt preparation is interrupted", () =>
Effect.gen(function* () {
const fixture = yield* setup()
const handle = fixture.sessions.forSession(sessionID)
yield* fixture.bus.publish(SessionEvent.RevertEvent.Prepared, {
sessionID,
snapshot: Snapshot.ID.make("original"),
paths: [RelativePath.make("file.txt")],
})
const entered = yield* Deferred.make<void>()
yield* fixture.hooks.register("session", "prompt", () =>
Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)),
)
const prompt = yield* handle.prompt({ text: "Not admitted" }).pipe(Effect.forkScoped)
yield* Deferred.await(entered)
yield* Fiber.interrupt(prompt)
expect(yield* fixture.store.hasPendingRevert(sessionID)).toBe(true)
expect(yield* handle.inbox()).toEqual([])
}),
)
it.live("selects the destination's snapshot service after a move", () =>
Effect.gen(function* () {
const captures: Location.Ref[] = []
@@ -121,9 +121,102 @@ describe("SessionProjector", () => {
expect(storedRevert?.files).toEqual([
{ file: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" },
])
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.RevertEvent.Prepared, { sessionID, paths: [RelativePath.make("src/new.ts")] })
expect((yield* db.select().from(SessionTable).get())?.revert_pending).toEqual({
snapshot: Snapshot.ID.make("tree"),
paths: ["src/old.ts", "src/new.ts"].map((file) => RelativePath.make(file)),
})
}),
)
it.effect("replays preparation deltas without publishing a staged boundary or changing activity", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const before = yield* store.get(sessionID)
const prepared = {
id: Event.ID.make("evt_revert_prepared"),
aggregateID: sessionID,
seq: 0,
type: Bus.versionedType(SessionEvent.RevertEvent.Prepared.type, 1),
created: 10,
data: { sessionID, snapshot: Snapshot.ID.make("original"), paths: [RelativePath.make("first.txt")] },
}
yield* bus.replay(prepared)
yield* bus.replay(prepared)
yield* bus.replay({
id: Event.ID.make("evt_revert_more_paths"),
aggregateID: sessionID,
seq: 1,
type: prepared.type,
created: 20,
data: { sessionID, paths: [RelativePath.make("second.txt")] },
})
expect(yield* store.get(sessionID)).toEqual(before)
expect((yield* db.select().from(SessionTable).get())?.revert_pending).toEqual({
snapshot: Snapshot.ID.make("original"),
paths: ["first.txt", "second.txt"].map((file) => RelativePath.make(file)),
})
yield* bus.publish(SessionEvent.RevertEvent.Cleared, { sessionID })
expect((yield* db.select().from(SessionTable).get())?.revert_pending).toBeNull()
}),
)
it.effect("rejects preparation without a new or previously recorded original", () =>
Effect.gen(function* () {
const db = yield* seedSession()
const bus = yield* Bus.Service
expect(
yield* bus
.publish(SessionEvent.RevertEvent.Prepared, { sessionID, paths: [RelativePath.make("first.txt")] })
.pipe(Effect.exit),
).toMatchObject({ _tag: "Failure" })
expect((yield* db.select().from(SessionTable).get())?.revert_pending).toBeNull()
expect(yield* db.select().from(EventTable).all()).toEqual([])
}),
)
for (const type of ["user", "compaction"] as const) {
it.effect(`replayed ${type} admission accepts failed preparation but synthetic admission does not`, () =>
Effect.gen(function* () {
yield* seedSession()
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
yield* bus.publish(SessionEvent.RevertEvent.Prepared, {
sessionID,
snapshot: Snapshot.ID.make("original"),
paths: [RelativePath.make("file.txt")],
})
yield* bus.replay({
id: Event.ID.make("evt_notice"),
aggregateID: sessionID,
seq: 1,
type: Bus.versionedType(SessionEvent.InboxEnqueued.type, 1),
data: {
sessionID,
inboxID: SessionMessage.ID.make("msg_notice"),
item: { type: "synthetic", payload: { text: "Background notice" }, delivery: "steer" },
},
})
expect(yield* store.hasPendingRevert(sessionID)).toBe(true)
yield* bus.replay({
id: Event.ID.make("evt_accept"),
aggregateID: sessionID,
seq: 2,
type: Bus.versionedType(SessionEvent.InboxEnqueued.type, 1),
data: {
sessionID,
inboxID: SessionMessage.ID.make("msg_accept"),
item: { type, payload: type === "user" ? { text: "Continue" } : {}, delivery: "steer" },
},
})
expect(yield* store.hasPendingRevert(sessionID)).toBe(false)
}),
)
}
it.effect("projects staged, cleared, and committed reverts", () =>
Effect.gen(function* () {
const db = yield* seedSession({
@@ -171,6 +264,11 @@ describe("SessionProjector", () => {
})
.run()
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.RevertEvent.Prepared, {
sessionID,
snapshot: Snapshot.ID.make("tree"),
paths: [RelativePath.make("first.txt")],
})
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), files: [] },
@@ -180,12 +278,24 @@ describe("SessionProjector", () => {
snapshot: "tree",
files: [],
})
expect((yield* db.select().from(SessionTable).get())?.revert_pending).toBeNull()
yield* bus.publish(SessionEvent.RevertEvent.Prepared, { sessionID, paths: [RelativePath.make("second.txt")] })
expect((yield* db.select().from(SessionTable).get())?.revert_pending).toEqual({
snapshot: Snapshot.ID.make("tree"),
paths: [RelativePath.make("second.txt")],
})
yield* bus.publish(SessionEvent.RevertEvent.Cleared, { sessionID })
expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toBeNull()
expect((yield* db.select().from(SessionTable).get())?.revert_pending).toBeNull()
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID: boundary, files: [] },
})
yield* bus.publish(SessionEvent.RevertEvent.Prepared, {
sessionID,
snapshot: Snapshot.ID.make("next-original"),
paths: [RelativePath.make("third.txt")],
})
yield* bus.publish(SessionEvent.RevertEvent.Committed, {
sessionID,
to: boundary,
@@ -200,6 +310,7 @@ describe("SessionProjector", () => {
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
revert_pending: null,
})
// 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()
@@ -0,0 +1,472 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Exit, Layer, Schema, Scope, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { Money } from "@opencode-ai/schema/money"
import { Provider } from "@opencode-ai/schema/provider"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Bus } from "../src/bus.js"
import { Database } from "../src/database/database.js"
import { EventTable } from "../src/event/sql.js"
import { Location } from "../src/location.js"
import { Instance } from "../src/instance/service.js"
import { AbsolutePath, RelativePath } from "../src/schema.js"
import { SessionEvent } from "../src/session/event.js"
import { SessionMessage } from "../src/session/message.js"
import { SessionProjector } from "../src/session/projector.js"
import { SessionRevert } from "../src/session/revert.js"
import { SessionSchema } from "../src/session/schema.js"
import { SessionStore } from "../src/session/store.js"
import { SessionTable } from "../src/session/sql.js"
import { Snapshot } from "../src/snapshot.js"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
// Root and Windows do not enforce this fixture's POSIX directory permissions.
const permissionTest = process.platform === "win32" || process.getuid?.() === 0 ? it.live.skip : it.live
const sessionID = SessionSchema.ID.make("ses_revert_recovery")
const edited = {
"first.txt": "edited:first\n",
"locked/file.txt": "edited:locked\n",
"later.txt": "edited:later\n",
"unselected.txt": "keep this later edit\n",
}
const fixture = Effect.fnUntraced(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const write = (files: Record<string, string>) =>
Effect.promise(() =>
Promise.all(Object.entries(files).map(([file, text]) => Bun.write(path.join(directory, file), text))),
)
yield* Effect.promise(() => fs.mkdir(path.join(directory, "locked"), { recursive: true }))
yield* write({
"first.txt": "saved:first\n",
"locked/file.txt": "saved:locked\n",
"later.txt": "saved:later\n",
"unselected.txt": "saved:unselected\n",
})
yield* Effect.promise(async () => {
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
})
const allow = Effect.promise(() => fs.chmod(path.join(directory, "locked"), 0o755))
yield* Effect.addFinalizer(() => allow)
const open = Effect.fnUntraced(function* (filename = "session.sqlite") {
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* Layer.buildWithScope(
LayerNode.compile(
LayerNode.group([
Location.node,
Snapshot.node,
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
]),
{
replacements: [
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make(directory) })),
Global.node.replace(
Global.layerWith({
home: tmp.path,
data: path.join(tmp.path, "data"),
cache: path.join(tmp.path, "cache"),
config: path.join(tmp.path, "config"),
state: path.join(tmp.path, "state"),
}),
),
Database.node.replace(Database.configured({ path: path.join(tmp.path, filename) })),
Bus.node.replace(Bus.configured({ persist: true })),
],
},
),
scope,
)
return yield* Effect.gen(function* () {
const bus = yield* Bus.Service
const database = yield* Database.Service
const store = yield* SessionStore.Service
const snapshot = yield* Snapshot.Service
const location = yield* Location.Service
const makeRevert = (overrides: Partial<Snapshot.Interface> = {}) => {
const provide = Effect.provide(
Layer.mergeAll(
Layer.succeed(Bus.Service, bus),
Layer.succeed(Database.Service, database),
Layer.succeed(Instance.Service, {
// Revert only requests Snapshot; the fixture supplies that real, scope-owned instance capability.
provide: () =>
Effect.provide(
Layer.succeed(Snapshot.Service, { ...snapshot, ...overrides }) as Layer.Layer<Instance.Services>,
),
}),
),
)
return {
stage: (input: Parameters<typeof SessionRevert.stage>[0]) => SessionRevert.stage(input).pipe(provide),
clear: (session: SessionSchema.Info) => SessionRevert.clear(session).pipe(provide),
}
}
const revert = makeRevert()
const get = Effect.fnUntraced(function* () {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die("Missing recovery fixture session")
return session
})
return {
bus,
db: database.db,
snapshot,
location,
get,
makeRevert,
pending: Effect.suspend(() =>
database.db
.select({ pending: SessionTable.revert_pending })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(
Effect.orDie,
Effect.map((row) => row?.pending),
),
),
preparations: bus.log({ aggregateID: sessionID }).pipe(
Stream.runCollect,
Effect.map((events) =>
events.filter(Schema.is(SessionEvent.RevertEvent.Prepared)).map((event) => event.data),
),
),
close: Scope.close(scope, Exit.void),
stage: (messageID: SessionMessage.ID, files?: boolean) =>
get().pipe(Effect.flatMap((session) => revert.stage({ session, messageID, files }))),
clear: () => get().pipe(Effect.flatMap(revert.clear)),
}
}).pipe(Effect.provide(context))
})
const services = yield* open()
yield* services.bus.publish(SessionEvent.Created, {
sessionID,
projectID: services.location.project.id,
location: { directory: AbsolutePath.make(directory) },
slug: "recovery",
version: "test",
})
const step = Effect.fnUntraced(function* (files: Record<string, string>) {
const prompt = yield* services.bus.publish(SessionEvent.Synthetic, { sessionID, text: "Edit files" })
const before = yield* services.snapshot.capture()
if (!before) return yield* Effect.die("Missing initial snapshot")
const assistantMessageID = SessionMessage.ID.create()
yield* services.bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
agent: Agent.ID.make("build"),
model: { id: Model.ID.make("test"), providerID: Provider.ID.make("test") },
snapshot: before,
})
yield* write(files)
const after = yield* services.snapshot.capture()
if (!after) return yield* Effect.die("Missing edited snapshot")
yield* services.bus.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
snapshot: after,
files: yield* services.snapshot.files({ from: before, to: after }),
})
return SessionMessage.ID.fromEvent(prompt.id)
})
// Git visits the writable file before the locked path even without batched checkouts.
const earlier = yield* step({ "first.txt": edited["first.txt"], "locked/file.txt": edited["locked/file.txt"] })
const later = yield* step({ "later.txt": edited["later.txt"] })
yield* write({ "unselected.txt": edited["unselected.txt"] })
const original = yield* services.snapshot.capture()
if (!original) return yield* Effect.die("Missing original snapshot")
return {
...services,
earlier,
later,
original,
open,
write,
allow,
deny: Effect.promise(() => fs.chmod(path.join(directory, "locked"), 0o555)),
read: Effect.promise(async () =>
Object.fromEntries(
await Promise.all(
Object.keys(edited).map(async (file) => [file, await Bun.file(path.join(directory, file)).text()]),
),
),
),
}
})
describe("SessionRevert recovery", () => {
permissionTest("clears a failed first stage back to the original files", () =>
Effect.gen(function* () {
const state = yield* fixture()
yield* state.deny
expect(yield* state.stage(state.earlier).pipe(Effect.flip)).toMatchObject({
_tag: "Snapshot.Error",
operation: "restore",
})
expect((yield* state.read)["first.txt"]).toBe("saved:first\n")
expect((yield* state.get()).revert).toBeUndefined()
expect(yield* state.pending).toMatchObject({ snapshot: state.original })
yield* state.allow
yield* state.clear()
expect(yield* state.read).toEqual(edited)
expect((yield* state.get()).revert).toBeUndefined()
expect(yield* state.pending).toBeNull()
}),
)
permissionTest("retries the same boundary without adopting partially restored files as its original", () =>
Effect.gen(function* () {
const state = yield* fixture()
yield* state.deny
yield* state.stage(state.earlier).pipe(Effect.flip)
yield* state.allow
const staged = yield* state.stage(state.earlier)
expect(staged.snapshot).toBe(state.original)
expect(yield* state.read).toEqual({
"first.txt": "saved:first\n",
"locked/file.txt": "saved:locked\n",
"later.txt": "saved:later\n",
"unselected.txt": edited["unselected.txt"],
})
expect(yield* state.pending).toBeNull()
yield* state.clear()
expect(yield* state.read).toEqual(edited)
}),
)
permissionTest("unwinds dropped paths when retrying at a different boundary", () =>
Effect.gen(function* () {
const state = yield* fixture()
yield* state.deny
yield* state.stage(state.earlier).pipe(Effect.flip)
yield* state.allow
const staged = yield* state.stage(state.later)
expect(staged.snapshot).toBe(state.original)
expect(staged.files?.map((file) => file.file)).toEqual(["later.txt"])
expect(yield* state.read).toEqual({ ...edited, "later.txt": "saved:later\n" })
// Resolved paths must not remain protected across subsequent successful stages.
yield* state.write({ "first.txt": "new edit after moving the boundary\n" })
yield* state.stage(state.later)
yield* state.clear()
expect(yield* state.read).toEqual({ ...edited, "first.txt": "new edit after moving the boundary\n" })
}),
)
permissionTest("protects only new paths when a successful stage is followed by failed restaging", () =>
Effect.gen(function* () {
const state = yield* fixture()
const staged = yield* state.stage(state.later)
yield* state.deny
yield* state.stage(state.earlier).pipe(Effect.flip)
expect((yield* state.get()).revert).toEqual(staged)
expect(yield* state.preparations).toEqual([
{ sessionID, snapshot: state.original, paths: [RelativePath.make("later.txt")] },
{ sessionID, paths: ["first.txt", "locked/file.txt"].map((file) => RelativePath.make(file)) },
])
yield* state.allow
yield* state.clear()
expect(yield* state.read).toEqual(edited)
expect(yield* state.pending).toBeNull()
}),
)
permissionTest("keeps the first original through repeated failed attempts without redundant preparation events", () =>
Effect.gen(function* () {
const state = yield* fixture()
yield* state.deny
yield* state.stage(state.earlier).pipe(Effect.flip)
yield* state.stage(state.earlier).pipe(Effect.flip)
expect(yield* state.preparations).toEqual([
{
sessionID,
snapshot: state.original,
paths: ["first.txt", "locked/file.txt", "later.txt"].map((file) => RelativePath.make(file)),
},
])
expect(yield* state.pending).toMatchObject({ snapshot: state.original })
yield* state.allow
yield* state.clear()
expect(yield* state.read).toEqual(edited)
}),
)
permissionTest("accumulates new protected paths across failures at different boundaries", () =>
Effect.gen(function* () {
const state = yield* fixture()
const revert = state.makeRevert({
diff: () => Effect.fail(new Snapshot.Error({ operation: "diff", message: "Diff unavailable" })),
})
yield* revert.stage({ session: yield* state.get(), messageID: state.later }).pipe(Effect.flip)
yield* state.deny
yield* state.stage(state.earlier).pipe(Effect.flip)
expect(yield* state.preparations).toEqual([
{ sessionID, snapshot: state.original, paths: [RelativePath.make("later.txt")] },
{ sessionID, paths: ["first.txt", "locked/file.txt"].map((file) => RelativePath.make(file)) },
])
yield* state.allow
expect((yield* state.stage(state.earlier)).snapshot).toBe(state.original)
yield* state.clear()
expect(yield* state.read).toEqual(edited)
}),
)
permissionTest("keeps recovery available when clearing a staged revert also fails", () =>
Effect.gen(function* () {
const state = yield* fixture()
const staged = yield* state.stage(state.earlier)
yield* state.deny
expect(yield* state.clear().pipe(Effect.flip)).toMatchObject({ _tag: "Snapshot.Error", operation: "restore" })
expect((yield* state.get()).revert).toEqual(staged)
yield* state.allow
yield* state.clear()
expect(yield* state.read).toEqual(edited)
}),
)
permissionTest("unwinds failed preparation before staging with files:false", () =>
Effect.gen(function* () {
const state = yield* fixture()
yield* state.deny
yield* state.stage(state.earlier).pipe(Effect.flip)
yield* state.allow
expect(yield* state.stage(state.later, false)).toEqual({
messageID: state.later,
snapshot: state.original,
files: [],
})
expect(yield* state.read).toEqual(edited)
expect(yield* state.pending).toBeNull()
yield* state.write({ "first.txt": "new edit after files:false\n" })
yield* state.clear()
expect(yield* state.read).toEqual({ ...edited, "first.txt": "new edit after files:false\n" })
}),
)
it.live("does not prepare or change files for a first files:false stage", () =>
Effect.gen(function* () {
const state = yield* fixture()
expect((yield* state.stage(state.earlier, false)).files).toEqual([])
expect(yield* state.read).toEqual(edited)
expect(yield* state.preparations).toEqual([])
expect(yield* state.pending).toBeNull()
yield* state.clear()
expect(yield* state.read).toEqual(edited)
}),
)
it.live("fails before changing files when capture cannot supply an original", () =>
Effect.gen(function* () {
const state = yield* fixture()
const revert = state.makeRevert({ capture: () => Effect.undefined })
expect(
yield* revert.stage({ session: yield* state.get(), messageID: state.earlier }).pipe(Effect.flip),
).toMatchObject({ _tag: "Snapshot.Error", operation: "capture" })
expect(yield* state.read).toEqual(edited)
expect(yield* state.pending).toBeNull()
expect(yield* state.preparations).toEqual([])
expect((yield* state.get()).revert).toBeUndefined()
expect(
(yield* revert.stage({ session: yield* state.get(), messageID: state.earlier, files: false })).files,
).toEqual([])
}),
)
it.live("does not record preparation for a nonexistent boundary", () =>
Effect.gen(function* () {
const state = yield* fixture()
expect(yield* state.stage(SessionMessage.ID.create()).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
expect(yield* state.preparations).toEqual([])
expect(yield* state.pending).toBeNull()
expect(yield* state.read).toEqual(edited)
}),
)
it.live("retains the original when diff fails after files have been restored", () =>
Effect.gen(function* () {
const state = yield* fixture()
const revert = state.makeRevert({
diff: () => Effect.fail(new Snapshot.Error({ operation: "diff", message: "Diff unavailable" })),
})
expect(
yield* revert.stage({ session: yield* state.get(), messageID: state.earlier }).pipe(Effect.flip),
).toMatchObject({ _tag: "Snapshot.Error", operation: "diff" })
expect((yield* state.read)["locked/file.txt"]).toBe("saved:locked\n")
expect((yield* state.get()).revert).toBeUndefined()
expect(yield* state.pending).toMatchObject({ snapshot: state.original })
yield* state.clear()
expect(yield* state.read).toEqual(edited)
}),
)
permissionTest("recovers failed preparation after reopening the database and Snapshot services", () =>
Effect.gen(function* () {
const state = yield* fixture()
yield* state.deny
yield* state.stage(state.earlier).pipe(Effect.flip)
yield* state.close
yield* state.allow
const restarted = yield* state.open()
expect((yield* restarted.get()).revert).toBeUndefined()
expect((yield* restarted.stage(state.later)).snapshot).toBe(state.original)
expect(yield* state.read).toEqual({ ...edited, "later.txt": "saved:later\n" })
yield* restarted.clear()
expect(yield* state.read).toEqual(edited)
}),
)
permissionTest("replays unfinished preparation into a fresh database before clearing", () =>
Effect.gen(function* () {
const state = yield* fixture()
yield* state.deny
yield* state.stage(state.earlier).pipe(Effect.flip)
const pending = yield* state.pending
const partial = yield* state.read
const events = yield* state.db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
yield* state.close
yield* state.allow
const replayed = yield* state.open("replayed.sqlite")
yield* Effect.forEach(events, (event) =>
replayed.bus.replay({
id: event.id,
created: event.created,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
}),
)
expect(yield* state.read).toEqual(partial)
expect(yield* replayed.pending).toEqual(pending)
expect((yield* replayed.get()).revert).toBeUndefined()
yield* replayed.clear()
expect(yield* state.read).toEqual(edited)
expect(yield* replayed.pending).toBeNull()
}),
)
})
+18 -1
View File
@@ -32,7 +32,7 @@ import { EventTable } from "@opencode-ai/core/event/sql"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Form } from "@opencode-ai/core/form"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { SessionEvent } from "@opencode-ai/core/session/event"
@@ -3292,6 +3292,23 @@ describe("SessionRunnerLLM", () => {
])
})
scenario("leaves queued input untouched while undo recovery is pending", function* (s) {
const queued = yield* s.admit("Preserve this queued input")
yield* s.llm.push(TestLLM.stop())
yield* s.bus.publish(SessionEvent.RevertEvent.Prepared, {
sessionID,
snapshot: Snapshot.ID.make("original"),
paths: [RelativePath.make("file.txt")],
})
yield* s.resume
expect(s.requests).toHaveLength(0)
expect(yield* s.inbox).toMatchObject([{ id: queued.id }])
yield* s.bus.publish(SessionEvent.RevertEvent.Cleared, { sessionID })
yield* s.resume
expect(s.requests).toHaveLength(1)
expect(yield* s.inbox).toEqual([])
})
scenario("steers an active step with newly recorded prompts", function* (s) {
yield* s.admit("Start working")
+1
View File
@@ -55,6 +55,7 @@ const session = (
tokens_cache_read: 99,
tokens_cache_write: 99,
revert: null,
revert_pending: null,
permission: null,
agent: null,
model: null,
+13 -1
View File
@@ -607,6 +607,17 @@ export namespace Compaction {
}
export namespace RevertEvent {
export const Prepared = Event.durable({
type: "session.revert.prepared",
...options,
schema: {
...Base,
/** The original tree, only when no staged or pending revert already records it. */
snapshot: Snapshot.ID.pipe(optional),
/** Newly protected paths; projection retains previously protected paths. */
paths: Schema.Array(RelativePath),
},
})
export const Staged = Event.durable({
type: "session.revert.staged",
...options,
@@ -671,10 +682,11 @@ export const Definitions = Event.inventory(
MessageContentUpdated,
)
// UsageRecorded is durable but internal: excluded from Definitions so it never reaches the public manifest.
// Internal durable events stay out of the live public manifest, but remain available in the session log.
export const DurableDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "durable"),
UsageRecorded,
RevertEvent.Prepared,
)
export const EphemeralDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "ephemeral"),
@@ -21,6 +21,8 @@ import { McpEvent } from "../src/mcp-event.js"
import { SessionEvent } from "../src/session-event.js"
import { SessionID } from "../src/session-id.js"
import { SessionMessage } from "../src/session-message.js"
import { RelativePath } from "../src/schema.js"
import { Snapshot } from "../src/snapshot.js"
import { WorkspaceEvent } from "../src/workspace-event.js"
describe("public event manifest", () => {
@@ -150,6 +152,7 @@ describe("public event manifest", () => {
"session.compaction.ended.1",
"session.compaction.failed.1",
"session.revert.staged.1",
"session.revert.prepared.1",
"session.revert.cleared.1",
"session.revert.committed.1",
"worktree.resolved.1",
@@ -158,6 +161,7 @@ describe("public event manifest", () => {
expect(SessionEvent.DurableDefinitions).toEqual([
...SessionEvent.Definitions.filter((definition) => definition.durability === "durable"),
SessionEvent.UsageRecorded,
SessionEvent.RevertEvent.Prepared,
])
expect(SessionEvent.UsageRecorded.durability).toBe("durable")
expect(EventManifest.Durable.get("session.usage.recorded.1")).toBe(SessionEvent.UsageRecorded)
@@ -165,6 +169,11 @@ describe("public event manifest", () => {
expect(EventManifest.Definitions).not.toContain(SessionEvent.UsageRecorded)
expect(EventManifest.ServerDefinitions).not.toContain(SessionEvent.UsageRecorded)
expect(EventManifest.Latest.has("session.usage.recorded")).toBe(false)
expect(EventManifest.Durable.get("session.revert.prepared.1")).toBe(SessionEvent.RevertEvent.Prepared)
expect(SessionEvent.Definitions).not.toContain(SessionEvent.RevertEvent.Prepared)
expect(EventManifest.Definitions).not.toContain(SessionEvent.RevertEvent.Prepared)
expect(EventManifest.ServerDefinitions).not.toContain(SessionEvent.RevertEvent.Prepared)
expect(EventManifest.Latest.has("session.revert.prepared")).toBe(false)
expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral")
expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral")
expect(SessionEvent.Tool.Progress.durability).toBe("ephemeral")
@@ -174,6 +183,18 @@ describe("public event manifest", () => {
expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true)
})
test("encodes revert protection deltas without repeating an existing snapshot", () => {
const encode = Schema.encodeSync(SessionEvent.RevertEvent.Prepared.data)
const sessionID = SessionID.make("ses_prepared")
const paths = [RelativePath.make("src/file.ts")]
expect(encode({ sessionID, paths, snapshot: Snapshot.ID.make("original") })).toEqual({
sessionID,
paths,
snapshot: "original",
})
expect(encode({ sessionID, paths, snapshot: undefined })).toEqual({ sessionID, paths })
})
test("uses the current Session skill event as durable version 1", () => {
expect(EventManifest.Durable.get("session.skill.activated.1")).toBe(SessionEvent.Skill.Activated)
expect(EventManifest.Latest.get("session.skill.activated")).toBe(SessionEvent.Skill.Activated)
+3 -1
View File
@@ -44,7 +44,9 @@ Location services are acquired only when an operation needs them. In particular,
`servicesFor` selects instance services from the saved placement. Each instance constructs `SessionPrompt.Service`, whose `prepare` method turns submitted input into a user inbox item without admitting it, committing a revert, or waking execution. It captures FSUtil, PluginSupervisor, PluginHooks, Image, and Skill; readiness is checked before hooks on every call. Prompt keeps early retry reconciliation outside preparation and invokes preparation interruptibly in the current instance. Lower Session does not depend directly on Database or FSUtil; its Location requirements are SessionPrompt, SessionRevert, Shell, and the PluginSupervisor still used by manual shell startup.
Each instance constructs its `SessionRevert.Service` through `SessionRevert.make`, capturing Database, Bus, PluginSupervisor, and Snapshot. Stage and clear check plugin readiness on each invocation and require no service provisioning inside their implementations. Session methods select the current instance for each operation, so an ID-bound Session does not retain a previous instance's snapshots after movement. Commit uses only the host's captured Bus and does not acquire an instance.
`SessionRevert.stage` and `SessionRevert.clear` use the host Database and Bus and acquire Snapshot through `Instance.Service` for the saved Session placement. Session methods select the current instance for each operation, so an ID-bound Session does not retain a previous instance's snapshots after movement. Commit uses the host's captured Bus and SessionStore and does not acquire an instance.
Before restoring files, `session.revert.prepared` records a previously unrecorded original snapshot and newly protected paths. Its private `revert_pending` projection survives failed restores or diffs; retries and clear include those paths without treating partial restoration as the original. Only successful staging changes the public revert boundary. Staged, cleared, and committed events retire pending protection. Committing failed preparation without a staged boundary clears protection without touching files or deleting history. New user and manual-compaction inbox admissions retire that protection atomically with admission; retries and synthetic notices do not accept it. Retried admissions and explicit resumes cannot run model work while preparation is pending. Synthetic notices stay admitted without waking execution, matching staged-revert behavior.
`SessionInbox.Service` is host-scoped. Its node depends on Database and Bus, and its layer uses `SessionInbox.make` to capture those services and construct admission and pending-input commands that take only domain inputs. Its `list` method supplies the normal pending-input read without exposing Database to Session. `Session.make` and the facade's move admission consume the registered service rather than constructing separate command objects. The service is not Session-ID scoped and does not own execution. Its commands retain the existing shared inbox serialization lock. Standalone query helpers, transaction-facing projectors, and runner delivery retain their explicit database/Bus inputs. The layer compiler is unchanged.