From d181dbda7390f01f56198fd84780c05a6d93b7d7 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 31 Oct 2026 21:03:56 +0000 Subject: [PATCH 1/3] fix(opencode): OpenCode 2 retry rows keep the usage-limit class A retry scheduled for a rate limit or a 429 quota showed as a provider error while it ran, and only the terminal failure row was classed as a usage limit. Retry rows now share the terminal row's classification, so an out-of-funds 402 quota stays a provider error and the row stays retryable. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/OpenCode2AdapterV2.test.ts | 24 +++++++++++++++---- .../src/server/v2/adapter.ts | 8 ++++--- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index bb26a5dd53a30..af2f85a5d97a6 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -568,11 +568,27 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { const { runtime, thread } = yield* resumed([ out("session.prompt", { sessionID: SESSION, text: "" }), promptAccepted, + event("session.retry.scheduled", { + sessionID: SESSION, + assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z", + attempt: 2, + at: 2_001, + error, + }), event("session.execution.failed", { sessionID: SESSION, error }), ]); - const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + const collected = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); yield* runtime.startTurn(turnInput(thread)); - const ended = yield* Fiber.join(terminal); + const seen = yield* Fiber.join(collected); + assert.deepInclude(errorRows(seen)[0], { + status: "running", + failure: { class: failureClass, code: error.type, message: error.message, retryable: true }, + }); + const ended = seen.find((event) => event.type === "turn.terminal"); assert.equal(ended?.status, "failed"); assert.deepEqual(ended?.failure, { class: failureClass, @@ -2934,7 +2950,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { error: rateLimited, }); const retrying = { - class: "provider_error", + class: "usage_limit", message: rateLimited.message, code: rateLimited.type, retryable: true, @@ -3116,7 +3132,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ], gate, ); - const failed = { ...retrying, class: "usage_limit" as const, retryable: null }; + const failed = { ...retrying, retryable: null }; const lastRetry = { attempt: 2, maxAttempts: null, retryDelayMs: 4_000 }; const rows = errorRows(seen); assert.deepEqual( diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 74695bf6061aa..c237d9b6ebd70 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -512,6 +512,9 @@ const reportOutcome = (state: string | undefined) => const subagentOutput = (text: string) => /^]*>\n?([\s\S]*?)\n?<\/subagent>$/.exec(text.trim())?.[1] ?? text; +const isUsageLimit = (type: string, status: number | undefined) => + (type === "provider.quota" && status !== 402) || type === "provider.rate-limit"; + const isContinuation = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => turnInput.message.createdBy === "agent" && turnInput.message.creationSource === "provider"; @@ -1076,7 +1079,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr failure: makeProviderFailure({ message: readableProviderErrorMessage(error.message, error.status, error.response?.body), code: error.type, - class: "provider_error", + class: isUsageLimit(error.type, error.status) ? "usage_limit" : "provider_error", retryable: true, }), startedAt: turn.retry?.startedAt ?? (yield* DateTime.now), @@ -2129,8 +2132,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const { type, message, status, response } = event.data.error; // OpenCode types an empty balance as provider.quota too, but it answers // that with HTTP 402 and it never resets. Usage windows and budgets are 429. - const usageLimit = - (type === "provider.quota" && status !== 402) || type === "provider.rate-limit"; + const usageLimit = isUsageLimit(type, status); return yield* endExecution(state, turn, { status: "failed", failure: makeProviderFailure({ From 278a992f209a389fc7abccad391bfd5a9d731329 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 22:09:55 +0000 Subject: [PATCH 2/3] test(opencode): preserve Claude retry classification after merge --- .../orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index dc5794d7a0767..fe19891627d72 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -602,7 +602,12 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { const seen = yield* Fiber.join(collected); assert.deepInclude(errorRows(seen)[0], { status: "running", - failure: { class: failureClass, code: error.type, message: error.message, retryable: true }, + failure: { + class: error.type === "provider.unknown" ? "provider_error" : failureClass, + code: error.type, + message: error.message, + retryable: true, + }, }); const ended = seen.find((event) => event.type === "turn.terminal"); assert.equal(ended?.status, "failed"); From fbb1b40fee8041841992def0d2660f8ba901f3e5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 12:28:40 +0000 Subject: [PATCH 3/3] fix(opencode): Claude plan limits retry as usage_limit too Move the Claude limit text check into isUsageLimit so a retry row and the failure that ends its turn are classified the same way. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/OpenCode2AdapterV2.test.ts | 7 +----- .../src/server/v2/adapter.ts | 24 +++++++++++-------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 6dd5cf2eef5b7..73dbb42ca8752 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -695,12 +695,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { const seen = yield* Fiber.join(collected); assert.deepInclude(errorRows(seen)[0], { status: "running", - failure: { - class: error.type === "provider.unknown" ? "provider_error" : failureClass, - code: error.type, - message: error.message, - retryable: true, - }, + failure: { class: failureClass, code: error.type, message: error.message, retryable: true }, }); const ended = seen.find((event) => event.type === "turn.terminal"); assert.equal(ended?.status, "failed"); diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 10269010230d4..6604f84eb289e 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -577,9 +577,6 @@ const reportOutcome = (state: string | undefined) => const subagentOutput = (text: string) => /^]*>\n?([\s\S]*?)\n?<\/subagent>$/.exec(text.trim())?.[1] ?? text; -const isUsageLimit = (type: string, status: number | undefined) => - (type === "provider.quota" && status !== 402) || type === "provider.rate-limit"; - const isContinuation = (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) => turnInput.message.createdBy === "agent" && turnInput.message.creationSource === "provider"; @@ -593,6 +590,17 @@ const claudeUsageLimit = (message: string | undefined) => ? { resetAt: /\bResets at (\d{4}-\d\d-\d\dT[\d:.]+Z)\.?\s*$/.exec(message)?.[1] ?? null } : undefined; +// OpenCode types an empty balance as provider.quota too, but it answers +// that with HTTP 402 and it never resets. Usage windows and budgets are 429. +const isUsageLimit = (error: { + readonly type: string; + readonly status?: number | undefined; + readonly message: string; +}) => + (error.type === "provider.quota" && error.status !== 402) || + error.type === "provider.rate-limit" || + claudeUsageLimit(error.message) !== undefined; + const INTERRUPT_TIMEOUT = 10_000; const INSTRUCTIONS_KEY = "t3-code"; const RECONNECT_ATTEMPTS = 5; @@ -1468,7 +1476,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr failure: makeProviderFailure({ message: readableProviderErrorMessage(error.message, error.status, error.response?.body), code: error.type, - class: isUsageLimit(error.type, error.status) ? "usage_limit" : "provider_error", + class: isUsageLimit(error) ? "usage_limit" : "provider_error", retryable: true, }), startedAt: turn.retry?.startedAt ?? (yield* DateTime.now), @@ -2842,17 +2850,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return yield* endExecution(state, turn, { status: "interrupted" }); case "session.execution.failed": { const { type, message, status, response } = event.data.error; - // OpenCode types an empty balance as provider.quota too, but it answers - // that with HTTP 402 and it never resets. Usage windows and budgets are 429. - const claudeLimit = claudeUsageLimit(message); - const usageLimit = isUsageLimit(type, status) || claudeLimit !== undefined; return yield* endExecution(state, turn, { status: "failed", failure: makeProviderFailure({ message: readableProviderErrorMessage(message, status, response?.body), code: type, - class: usageLimit ? "usage_limit" : "provider_error", - resetAt: claudeLimit?.resetAt ?? null, + class: isUsageLimit(event.data.error) ? "usage_limit" : "provider_error", + resetAt: claudeUsageLimit(message)?.resetAt ?? null, }), }); }