浏览代码

fix: use mapError instead of orDie for context snapshot decoding (#30905)

Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
weiconghe 3 月之前
父节点
当前提交
a261b55e43

+ 1 - 1
packages/core/src/session.ts

@@ -92,7 +92,7 @@ export class OperationUnavailableError extends Schema.TaggedErrorClass<Operation
   },
 ) {}
 
-export { MessageDecodeError } from "./session/error"
+export { ContextSnapshotDecodeError, MessageDecodeError } from "./session/error"
 
 export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
   sessionID: SessionSchema.ID,

+ 5 - 2
packages/core/src/session/context-epoch.ts

@@ -7,6 +7,7 @@ import { EventV2 } from "../event"
 import { Location } from "../location"
 import { SystemContext } from "../system-context"
 import { SystemContextRegistry } from "../system-context-registry"
+import { ContextSnapshotDecodeError } from "./error"
 import { SessionEvent } from "./event"
 import { SessionInput } from "./input"
 import { SessionMessageID } from "./message-id"
@@ -49,7 +50,7 @@ export function prepare(
   context: SystemContextRegistry.Interface,
   sessionID: SessionSchema.ID,
   location: Location.Ref,
-): Effect.Effect<Prepared, SystemContext.InitializationBlocked> {
+): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError> {
   return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location)).pipe(
     Effect.withSpan("SessionContextEpoch.prepare"),
   )
@@ -69,7 +70,9 @@ const prepareOnce = Effect.fnUntraced(function* (
     return { baseline: generation.baseline, baselineSeq }
   }
 
-  const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(Effect.orDie)
+  const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(
+    Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })),
+  )
   const result =
     stored.replacement_seq === null
       ? yield* SystemContext.reconcile(value, snapshot)

+ 12 - 0
packages/core/src/session/error.ts

@@ -6,3 +6,15 @@ export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeErr
   sessionID: SessionSchema.ID,
   messageID: SessionMessage.ID,
 }) {}
+
+export class ContextSnapshotDecodeError extends Schema.TaggedErrorClass<ContextSnapshotDecodeError>()(
+  "Session.ContextSnapshotDecodeError",
+  {
+    sessionID: SessionSchema.ID,
+    details: Schema.String,
+  },
+) {
+  override get message() {
+    return `Failed to decode context snapshot for session ${this.sessionID}: ${this.details}`
+  }
+}

+ 2 - 1
packages/core/src/session/runner/index.ts

@@ -3,7 +3,7 @@ export * as SessionRunner from "./index"
 import type { LLMError } from "@opencode-ai/llm"
 import { Context, Effect, Schema } from "effect"
 import { SessionSchema } from "../schema"
-import type { MessageDecodeError } from "../error"
+import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
 import { SessionRunnerModel } from "./model"
 import type { SystemContext } from "../../system-context"
 
@@ -19,6 +19,7 @@ export type RunError =
   | LLMError
   | SessionRunnerModel.Error
   | MessageDecodeError
+  | ContextSnapshotDecodeError
   | StepLimitExceededError
   | SystemContext.InitializationBlocked
 

+ 26 - 0
packages/core/test/session-runner.test.ts

@@ -19,6 +19,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { QuestionV2 } from "@opencode-ai/core/question"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
+import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
 import { SessionEvent } from "@opencode-ai/core/session/event"
 import { SessionInput } from "@opencode-ai/core/session/input"
 import { SessionMessage } from "@opencode-ai/core/session/message"
@@ -631,6 +632,31 @@ describe("SessionRunnerLLM", () => {
     }),
   )
 
+  it.effect("fails gracefully when a stored context snapshot cannot be decoded", () =>
+    Effect.gen(function* () {
+      yield* setup
+      const session = yield* SessionV2.Service
+      const { db } = yield* Database.Service
+      yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
+      response = []
+      yield* session.resume(sessionID)
+      yield* db
+        .update(SessionContextEpochTable)
+        .set({ snapshot: { invalid: { value: "bad" } } })
+        .where(eq(SessionContextEpochTable.session_id, sessionID))
+        .run()
+        .pipe(Effect.orDie)
+      yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
+      requests.length = 0
+
+      const exit = yield* session.resume(sessionID).pipe(Effect.exit)
+
+      expect(Exit.isFailure(exit)).toBe(true)
+      if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(ContextSnapshotDecodeError)
+      expect(requests).toHaveLength(0)
+    }),
+  )
+
   it.effect("does not create a source Location epoch after a concurrent Session move", () =>
     Effect.gen(function* () {
       yield* setup