From 242d693369338075483a2de0d0ee503669bf6032 Mon Sep 17 00:00:00 2001 From: Gabe O'Leary Date: Tue, 8 Sep 2026 17:39:59 -0700 Subject: [PATCH] Fall back to a configured sibling model when a provider is down Reads REPLO_OPENCODE_FALLBACK_CONFIG, which the coordinator already ships into every sandbox, and uses it inside the session retry schedule: once same-provider retries are spent on a listed status or a transport error, the step swaps to the mapped model, marks the failing provider degraded for the cooldown, and later steps start on the healthy route. The assistant message records the model that actually answered. Also stops marking an empty trailing text part for prompt caching, which Anthropic rejects. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/provider/transform.ts | 4 + packages/opencode/src/session/fallback.ts | 120 ++++++++++++++++++ packages/opencode/src/session/processor.ts | 46 ++++++- packages/opencode/src/session/prompt.ts | 8 +- packages/opencode/src/session/retry.ts | 39 ++++-- .../opencode/test/session/fallback.test.ts | 91 +++++++++++++ .../test/session/processor-effect.test.ts | 84 +++++++++++- packages/opencode/test/session/retry.test.ts | 40 +++++- 8 files changed, 412 insertions(+), 20 deletions(-) create mode 100644 packages/opencode/src/session/fallback.ts create mode 100644 packages/opencode/test/session/fallback.test.ts diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 5e5a14171fd3..e3cf4366a69b 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -362,9 +362,13 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage if (shouldUseContentOptions) { const lastContent = msg.content[msg.content.length - 1] + // Anthropic rejects cache_control on empty text blocks, so an empty tail + // part falls through to the message-level marker. + const emptyText = typeof lastContent === "object" && lastContent.type === "text" && !lastContent.text if ( lastContent && typeof lastContent === "object" && + !emptyText && lastContent.type !== "tool-approval-request" && lastContent.type !== "tool-approval-response" ) { diff --git a/packages/opencode/src/session/fallback.ts b/packages/opencode/src/session/fallback.ts new file mode 100644 index 000000000000..4280077cdb7f --- /dev/null +++ b/packages/opencode/src/session/fallback.ts @@ -0,0 +1,120 @@ +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { Schema } from "effect" +import { isRecord } from "@/util/record" +import type { Err } from "./retry" + +// The coordinator ships this per sandbox; it is the same shape the removed +// model-fallback plugin consumed, so nothing upstream has to change. +export const ENV_VAR = "REPLO_OPENCODE_FALLBACK_CONFIG" + +const ConfigSchema = Schema.Struct({ + fallbackModelsByModel: Schema.Record(Schema.String, Schema.String), + fallbackOnErrors: Schema.Array(Schema.Number), + maxFallbackAttempts: Schema.Number, + maxUpstreamRetryAttempts: Schema.Number, + cooldownSeconds: Schema.Number, +}) +export type Config = Schema.Schema.Type + +export type ModelRef = { providerID: string; modelID: string } + +const NETWORK_ERROR_PATTERN = + /fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|socket hang up|network error|terminated/i + +let loaded = false +let current: Config | null = null +const degradedUntil = new Map() + +export function config(): Config | null { + if (loaded) return current + loaded = true + const raw = process.env[ENV_VAR] + if (!raw) return null + try { + current = Schema.decodeUnknownSync(ConfigSchema)(JSON.parse(raw)) + } catch (error) { + console.error("[model-fallback] ignoring invalid config", error) + } + return current +} + +// Tests inject a config instead of setting the env var. +export function configure(next: Config | null) { + loaded = true + current = next + degradedUntil.clear() +} + +export function reset() { + loaded = false + current = null + degradedUntil.clear() +} + +export function key(model: ModelRef) { + return `${model.providerID}/${model.modelID}` +} + +export function parse(value: string): ModelRef { + const index = value.indexOf("/") + return { providerID: value.slice(0, index), modelID: value.slice(index + 1) } +} + +export function fallbackFor(model: ModelRef): ModelRef | undefined { + const target = config()?.fallbackModelsByModel[key(model)] + return target ? parse(target) : undefined +} + +export function markDegraded(providerID: string, now = Date.now()) { + const cfg = config() + if (!cfg) return + degradedUntil.set(providerID, now + cfg.cooldownSeconds * 1000) +} + +export function isDegraded(providerID: string, now = Date.now()) { + const until = degradedUntil.get(providerID) + if (until === undefined) return false + if (until > now) return true + degradedUntil.delete(providerID) + return false +} + +// Follows the fallback chain past every provider that is cooling down, so the +// steps after a failure start on the route that just worked. +export function healthy(model: ModelRef, now = Date.now()): ModelRef { + const seen = new Set([key(model)]) + let candidate = model + while (isDegraded(candidate.providerID, now)) { + const next = fallbackFor(candidate) + if (!next || seen.has(key(next))) return model + seen.add(key(next)) + candidate = next + } + return candidate +} + +// Transport-level failures the fallback config lists, plus errors with no +// status at all (the request never reached a provider). Context overflow and +// aborts are the caller's problem and never switch models. +export function qualifies(error: Err): boolean { + const cfg = config() + if (!cfg) return false + if (SessionV1.ContextOverflowError.isInstance(error)) return false + if (SessionV1.AbortedError.isInstance(error)) return false + if (SessionV1.APIError.isInstance(error)) { + const status = error.data.statusCode + if (status === undefined) return true + return cfg.fallbackOnErrors.includes(status) || status === 402 + } + const message = isRecord(error.data) ? error.data.message : undefined + return typeof message === "string" && NETWORK_ERROR_PATTERN.test(message) +} + +export function next(input: { model: ModelRef; error: Err; swaps: number }): ModelRef | undefined { + const cfg = config() + if (!cfg || input.swaps >= cfg.maxFallbackAttempts) return undefined + if (!qualifies(input.error)) return undefined + return fallbackFor(input.model) +} + +export * as SessionFallback from "./fallback" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 4a68e0bf99fe..a168ec58b019 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -3,7 +3,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Image } from "@/image/image" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Cause, Deferred, Effect, Exit, Fiber, Layer, Context, Scope, Schema } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Context, Option, Scope, Schema } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Config } from "@/config/config" @@ -17,9 +17,12 @@ import { isOverflow } from "./overflow" import { PartID } from "./schema" import type { SessionID } from "./schema" import { SessionRetry } from "./retry" +import { SessionFallback } from "./fallback" import { SessionStatus } from "./status" import { SessionSummary } from "./summary" -import type { Provider } from "@/provider/provider" +import { Provider } from "@/provider/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" import { Question } from "@/question" import { errorMessage } from "@/util/error" import { isRecord } from "@/util/record" @@ -82,6 +85,7 @@ interface ProcessorContext extends Input { firstRequestStartAt: number | undefined snapshotMs: number reasoningMap: Record + fallbacks: number } type StreamEvent = LLMEvent @@ -104,6 +108,7 @@ const layer = Layer.effect( const image = yield* Image.Service const events = yield* EventV2Bridge.Service const database = yield* Database.Service + const provider = yield* Provider.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { const ctx: ProcessorContext = { @@ -121,6 +126,7 @@ const layer = Layer.effect( requestStartAt: undefined, firstRequestStartAt: undefined, reasoningMap: {}, + fallbacks: 0, } let aborted = false @@ -152,10 +158,37 @@ const layer = Layer.effect( const parse = (e: unknown) => MessageV2.fromError(e, { - providerID: input.model.providerID, + providerID: ctx.model.providerID, aborted, }) + // Swaps the step onto the configured fallback model and records the + // switch on the assistant message, so the persisted row names the model + // that actually answered. + const fallback = (error: SessionRetry.Err) => + Effect.gen(function* () { + const from = { providerID: ctx.model.providerID, modelID: ctx.model.id } + const target = SessionFallback.next({ model: from, error, swaps: ctx.fallbacks }) + if (!target) return undefined + const resolved = yield* provider + .getModel(ProviderV2.ID.make(target.providerID), ModelV2.ID.make(target.modelID)) + .pipe(Effect.option) + if (Option.isNone(resolved)) return undefined + SessionFallback.markDegraded(from.providerID) + ctx.model = resolved.value + ctx.fallbacks += 1 + ctx.assistantMessage.providerID = resolved.value.providerID + ctx.assistantMessage.modelID = resolved.value.id + yield* session.updateMessage(ctx.assistantMessage) + yield* Effect.logWarning("[model-fallback] switched model after provider failure", { + "session.id": ctx.sessionID, + from: SessionFallback.key(from), + to: SessionFallback.key(target), + error: error.data, + }) + return { message: `${from.providerID} unavailable, continuing on ${target.providerID}/${target.modelID}` } + }) + const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) { const done = ctx.toolcalls[toolCallID]?.done delete ctx.toolcalls[toolCallID] @@ -678,7 +711,7 @@ const layer = Layer.effect( ctx.requestStartAt = Date.now() // Retries re-stamp requestStartAt; keep the first attempt so prep_ms excludes backoff. if (ctx.firstRequestStartAt === undefined) ctx.firstRequestStartAt = ctx.requestStartAt - const stream = llm.stream(streamInput) + const stream = llm.stream({ ...streamInput, model: ctx.model }) yield* stream.pipe( Stream.tap((event) => handleEvent(event)), @@ -700,7 +733,9 @@ const layer = Layer.effect( ), Effect.retry( SessionRetry.policy({ - provider: input.model.providerID, + provider: () => ctx.model.providerID, + attempts: SessionFallback.config()?.maxUpstreamRetryAttempts, + fallback, parse, set: (info) => { return status.set(ctx.sessionID, { @@ -766,6 +801,7 @@ export const node = LayerNode.make({ Image.node, EventV2Bridge.node, Database.node, + Provider.node, ], }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0fc8e54dbbf2..27ecb9b40ede 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -35,6 +35,7 @@ import { Tool } from "@/tool/tool" import { Permission } from "@/permission" import { Question } from "@/question" import { SessionStatus } from "./status" +import { SessionFallback } from "./fallback" import { LLM } from "./llm" import { Shell } from "@opencode-ai/core/shell" import { ShellID } from "@/tool/shell/id" @@ -1181,7 +1182,12 @@ const layer = Layer.effect( history: msgs, }).pipe(Effect.ignore, Effect.forkIn(scope)) - const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID) + const requested = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID) + const route = SessionFallback.healthy({ providerID: requested.providerID, modelID: requested.id }) + const model = + route.providerID === requested.providerID && route.modelID === requested.id + ? requested + : yield* getModel(ProviderV2.ID.make(route.providerID), ModelV2.ID.make(route.modelID), sessionID) const task = tasks.pop() if (task?.type === "subtask") { diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 4139665bd2bd..2580eeb94ab5 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -174,25 +174,42 @@ function parseJSON(value: unknown) { } export function policy(opts: { - provider: string + provider: string | (() => string) parse: (error: unknown) => Err set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect + // Same-provider retries stop after this many attempts; unset keeps today's unbounded behaviour. + attempts?: number + // Asked once retries are exhausted or the error is not retryable. Returning a + // message means the caller swapped models and the schedule should continue at once. + fallback?: (error: Err, attempt: number) => Effect.Effect }) { return Schedule.fromStepWithMetadata( Effect.succeed((meta: Schedule.InputMetadata) => { const error = opts.parse(meta.input) - const retry = retryable(error, opts.provider) - if (!retry) return Cause.done(meta.attempt) + const provider = typeof opts.provider === "function" ? opts.provider() : opts.provider + const retry = retryable(error, provider) + const exhausted = opts.attempts !== undefined && meta.attempt > opts.attempts + if (retry && !exhausted) { + return Effect.gen(function* () { + const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined) + const now = yield* Clock.currentTimeMillis + yield* opts.set({ + attempt: meta.attempt, + message: retry.message, + action: retry.action, + next: now + wait, + }) + return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration] + }) + } + const fallback = opts.fallback + if (!fallback) return Cause.done(meta.attempt) return Effect.gen(function* () { - const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined) + const swapped = yield* fallback(error, meta.attempt) + if (!swapped) return yield* Cause.done(meta.attempt) const now = yield* Clock.currentTimeMillis - yield* opts.set({ - attempt: meta.attempt, - message: retry.message, - action: retry.action, - next: now + wait, - }) - return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration] + yield* opts.set({ attempt: meta.attempt, message: swapped.message, action: swapped.action, next: now }) + return [meta.attempt, Duration.zero] as [number, Duration.Duration] }) }), ) diff --git a/packages/opencode/test/session/fallback.test.ts b/packages/opencode/test/session/fallback.test.ts new file mode 100644 index 000000000000..07e05d551069 --- /dev/null +++ b/packages/opencode/test/session/fallback.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { Schema } from "effect" +import { SessionFallback } from "../../src/session/fallback" + +const cfg: SessionFallback.Config = { + fallbackModelsByModel: { + "openrouter/anthropic/claude-sonnet-5": "anthropic/claude-sonnet-5", + "anthropic/claude-sonnet-5": "openrouter/anthropic/claude-sonnet-5", + }, + fallbackOnErrors: [429, 503], + maxFallbackAttempts: 1, + maxUpstreamRetryAttempts: 1, + cooldownSeconds: 60, +} +const openrouter = { providerID: "openrouter", modelID: "anthropic/claude-sonnet-5" } +const anthropic = { providerID: "anthropic", modelID: "claude-sonnet-5" } + +function apiError(statusCode?: number) { + return Schema.decodeUnknownSync(SessionV1.APIError.Schema)( + new SessionV1.APIError({ message: "boom", isRetryable: true, statusCode }).toObject(), + ) +} + +afterEach(() => { + delete process.env[SessionFallback.ENV_VAR] + SessionFallback.reset() +}) + +describe("session.fallback config", () => { + test("reads the coordinator env var once", () => { + process.env[SessionFallback.ENV_VAR] = JSON.stringify(cfg) + expect(SessionFallback.config()).toEqual(cfg) + delete process.env[SessionFallback.ENV_VAR] + expect(SessionFallback.config()).toEqual(cfg) + }) + + test("ignores a malformed env var", () => { + process.env[SessionFallback.ENV_VAR] = '{"fallbackOnErrors": "nope"}' + expect(SessionFallback.config()).toBeNull() + process.env[SessionFallback.ENV_VAR] = "not json" + SessionFallback.reset() + expect(SessionFallback.config()).toBeNull() + }) + + test("does nothing without config", () => { + expect(SessionFallback.next({ model: openrouter, error: apiError(503), swaps: 0 })).toBeUndefined() + expect(SessionFallback.healthy(openrouter)).toEqual(openrouter) + }) +}) + +describe("session.fallback next", () => { + test("switches on listed statuses and on transport errors", () => { + SessionFallback.configure(cfg) + expect(SessionFallback.next({ model: openrouter, error: apiError(503), swaps: 0 })).toEqual(anthropic) + expect(SessionFallback.next({ model: openrouter, error: apiError(402), swaps: 0 })).toEqual(anthropic) + expect(SessionFallback.next({ model: openrouter, error: apiError(), swaps: 0 })).toEqual(anthropic) + expect(SessionFallback.next({ model: anthropic, error: apiError(429), swaps: 0 })).toEqual(openrouter) + }) + + test("stays put on client errors, context overflow, and unmapped models", () => { + SessionFallback.configure(cfg) + expect(SessionFallback.next({ model: openrouter, error: apiError(400), swaps: 0 })).toBeUndefined() + const overflow = new SessionV1.ContextOverflowError({ message: "too long" }).toObject() + expect(SessionFallback.next({ model: openrouter, error: overflow, swaps: 0 })).toBeUndefined() + const unmapped = { providerID: "openrouter", modelID: "openai/gpt-5.6" } + expect(SessionFallback.next({ model: unmapped, error: apiError(503), swaps: 0 })).toBeUndefined() + }) + + test("caps swaps per step", () => { + SessionFallback.configure(cfg) + expect(SessionFallback.next({ model: openrouter, error: apiError(503), swaps: 1 })).toBeUndefined() + }) +}) + +describe("session.fallback healthy", () => { + test("routes later steps around a degraded provider until the cooldown ends", () => { + SessionFallback.configure(cfg) + const now = 1_000_000 + SessionFallback.markDegraded("openrouter", now) + expect(SessionFallback.healthy(openrouter, now)).toEqual(anthropic) + expect(SessionFallback.healthy(openrouter, now + 61_000)).toEqual(openrouter) + }) + + test("keeps the requested model when every provider in the cycle is degraded", () => { + SessionFallback.configure(cfg) + SessionFallback.markDegraded("openrouter") + SessionFallback.markDegraded("anthropic") + expect(SessionFallback.healthy(openrouter)).toEqual(openrouter) + }) +}) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 528760543656..ed663269332b 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -2,7 +2,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2Bridge } from "@/event-v2-bridge" -import { expect } from "bun:test" +import { afterEach, expect } from "bun:test" import { tool } from "ai" import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect" import path from "path" @@ -20,8 +20,9 @@ import { SessionSummary } from "../../src/session/summary" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { raw, reply, TestLLMServer } from "../lib/llm-server" +import { httpError, raw, reply, TestLLMServer } from "../lib/llm-server" import { RuntimeFlags } from "@/effect/runtime-flags" +import { SessionFallback } from "@/session/fallback" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -186,6 +187,8 @@ const env = LayerNode.compile( const it = testEffect(env) +afterEach(() => SessionFallback.reset()) + const providerErrorLLM = Layer.succeed( LLM.Service, LLM.Service.of({ @@ -285,6 +288,83 @@ it.live("session.processor effect tests capture llm input cleanly", () => ), ) +function fallbackCfg(url: string) { + const base = providerCfg(url) + return { + ...base, + provider: { + ...base.provider, + fallback: { + ...base.provider.test, + name: "Fallback", + id: "fallback", + models: { + "fallback-model": { + ...base.provider.test.models["test-model"], + id: "fallback-model", + name: "Fallback Model", + }, + }, + }, + }, + } +} + +it.live("session.processor effect tests switch to the fallback model after upstream retries", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + SessionFallback.configure({ + fallbackModelsByModel: { "test/test-model": "fallback/fallback-model" }, + fallbackOnErrors: [503], + maxFallbackAttempts: 1, + maxUpstreamRetryAttempts: 1, + cooldownSeconds: 60, + }) + const { processors, session, provider } = yield* boot() + + yield* llm.push(httpError(503, { error: { message: "down" } }), httpError(503, { error: { message: "down" } })) + yield* llm.text("hello") + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "hi") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies SessionV1.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "hi" }], + tools: {}, + }) + const parts = yield* MessageV2.parts(msg.id) + const stored = yield* MessageV2.get({ sessionID: chat.id, messageID: msg.id }) + + expect(value).toBe("continue") + expect(yield* llm.calls).toBe(3) + expect(parts.some((part) => part.type === "text" && part.text === "hello")).toBe(true) + expect(stored.info).toMatchObject({ providerID: "fallback", modelID: "fallback-model" }) + expect(SessionFallback.isDegraded("test")).toBe(true) + }), + { config: (url) => fallbackCfg(url) }, + ), +) + it.live("session.processor effect tests preserve text start time", () => provideTmpdirServer( ({ dir, llm }) => diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 30ac879a6a9d..9bd7e8d132b7 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -4,7 +4,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" import { setTimeout as sleep } from "node:timers/promises" -import { Effect, Schedule, Schema } from "effect" +import { Duration, Effect, Exit, Schedule, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" @@ -115,6 +115,44 @@ describe("session.retry.delay", () => { }) }), ) + + it.instance("policy hands off to fallback once same-provider attempts are spent", () => + Effect.gen(function* () { + const sessionID = SessionID.make("session-fallback-test") + const error = apiError({ "retry-after-ms": "0" }) + const status = yield* SessionStatus.Service + const seen: number[] = [] + + const step = yield* Schedule.toStepWithMetadata( + SessionRetry.policy({ + provider: () => "test", + attempts: 1, + parse: Schema.decodeUnknownSync(SessionV1.APIError.Schema), + fallback: (_, attempt) => + Effect.sync(() => { + seen.push(attempt) + return seen.length === 1 ? { message: "swapped" } : undefined + }), + set: (info) => + status.set(sessionID, { + type: "retry", + attempt: info.attempt, + message: info.message, + next: info.next, + }), + }), + ) + yield* step(error) + expect(seen).toEqual([]) + const swapped = yield* step(error) + expect(seen).toEqual([2]) + expect(Duration.toMillis(swapped.duration)).toBe(0) + expect(yield* status.get(sessionID)).toMatchObject({ type: "retry", attempt: 2, message: "swapped" }) + const exit = yield* Effect.exit(step(error)) + expect(Exit.isFailure(exit)).toBe(true) + expect(seen).toEqual([2, 3]) + }), + ) }) describe("session.retry.retryable", () => {