fix(runtime-fallback): preserve attemptCount when our own abort is the cause (closes #4006)
When runtime-fallback aborts an in-flight request to swap in a fallback
model, opencode emits session.error{isAbort:true} as a consequence. The
existing event handler treated that as a user cancellation and called
resetRetryState — wiping attemptCount. Every subsequent provider
auto-retry signal then started over at attempt:1, never reaching
max_fallback_attempts, producing an infinite retry loop firing a new
fallback every ~2 seconds.
The bug only surfaces when the configured fallback target itself
silently fails (e.g. github-copilot quota exhausted): the original
model keeps re-emitting retry signals, our handler keeps "fixing"
them, the counter never advances. Reproducible on upstream/dev HEAD
(5ffbe0e24e).
Fix:
- New `internallyAbortedSessions: Set<string>` on HookDeps tracks
sessions whose abort we triggered ourselves.
- abortSessionRequest in auto-retry.ts adds the session to the set
when called with one of our internal sources:
"session.status.retry-signal", "message.updated.retry-signal",
"session.timeout". The "session.stop" source (user-initiated) is
intentionally NOT marked — that path must still wipe state.
- handleSessionError in event-handler.ts checks the set before the
cancellation branch. If the session is marked, consume the flag
(delete it so a later user-abort still gets the reset) and skip
resetRetryState. The state's attemptCount is preserved, so the
next iteration progresses 1→2→3→... until max_fallback_attempts.
- dispose() clears the new set alongside the other per-session maps.
Tests: 3 new event-handler integration tests cover the fix
(internal-abort preserves state, external-abort still resets,
consecutive internal-abort cycles advance attemptCount). Existing
tests pass: 7/7 on event-handler. Pre-existing 2 dispose-test flakes
on the full runtime-fallback suite were verified to exist on
upstream/dev without this patch — unrelated.
bun run build: pass. bun run typecheck: pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,16 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
|||||||
} = deps
|
} = deps
|
||||||
|
|
||||||
const abortSessionRequest = async (sessionID: string, source: string): Promise<void> => {
|
const abortSessionRequest = async (sessionID: string, source: string): Promise<void> => {
|
||||||
|
// Sources we trigger ourselves to swap in a fallback model. Marking the
|
||||||
|
// session lets handleSessionError tell our abort apart from a user stop
|
||||||
|
// so it doesn't wipe attemptCount and re-enter the retry loop.
|
||||||
|
if (
|
||||||
|
source === "session.status.retry-signal" ||
|
||||||
|
source === "message.updated.retry-signal" ||
|
||||||
|
source === "session.timeout"
|
||||||
|
) {
|
||||||
|
deps.internallyAbortedSessions.add(sessionID)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await ctx.client.session.abort({ path: { id: sessionID } })
|
await ctx.client.session.abort({ path: { id: sessionID } })
|
||||||
log(`[${HOOK_NAME}] Aborted in-flight session request (${source})`, { sessionID })
|
log(`[${HOOK_NAME}] Aborted in-flight session request (${source})`, { sessionID })
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function createDeps(): HookDeps {
|
|||||||
sessionAwaitingFallbackResult: new Set(),
|
sessionAwaitingFallbackResult: new Set(),
|
||||||
sessionFallbackTimeouts: new Map(),
|
sessionFallbackTimeouts: new Map(),
|
||||||
sessionStatusRetryKeys: new Map(),
|
sessionStatusRetryKeys: new Map(),
|
||||||
|
internallyAbortedSessions: new Set(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,4 +163,89 @@ describe("createEventHandler", () => {
|
|||||||
expect(clearCalls).toEqual([sessionID])
|
expect(clearCalls).toEqual([sessionID])
|
||||||
expect(abortCalls).toEqual([])
|
expect(abortCalls).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("#given a session we aborted ourselves (internal abort flag set) #when session.error fires with isAbort #then fallback retry state is preserved (issue #4006)", async () => {
|
||||||
|
// given - we just called abortSessionRequest("session.status.retry-signal");
|
||||||
|
// opencode will emit session.error{isAbort:true} as a consequence. The
|
||||||
|
// handler must recognize this as our own abort and NOT wipe attemptCount,
|
||||||
|
// otherwise the next session.status retry signal restarts the loop at 1.
|
||||||
|
const sessionID = "session-internal-abort"
|
||||||
|
const deps = createDeps()
|
||||||
|
const abortCalls: string[] = []
|
||||||
|
const clearCalls: string[] = []
|
||||||
|
const state = createFallbackState("opencode-go/glm-5.1")
|
||||||
|
state.currentModel = "github-copilot/claude-haiku-4.5"
|
||||||
|
state.fallbackIndex = 0
|
||||||
|
state.attemptCount = 1
|
||||||
|
state.pendingFallbackModel = "github-copilot/claude-haiku-4.5"
|
||||||
|
deps.sessionStates.set(sessionID, state)
|
||||||
|
deps.internallyAbortedSessions.add(sessionID)
|
||||||
|
const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls))
|
||||||
|
|
||||||
|
// when
|
||||||
|
await handler({ event: { type: "session.error", properties: { sessionID, error: { name: "MessageAbortedError" } } } })
|
||||||
|
|
||||||
|
// then - state intact, attemptCount preserved
|
||||||
|
const preserved = deps.sessionStates.get(sessionID)
|
||||||
|
expect(preserved?.attemptCount).toBe(1)
|
||||||
|
expect(preserved?.currentModel).toBe("github-copilot/claude-haiku-4.5")
|
||||||
|
expect(preserved?.fallbackIndex).toBe(0)
|
||||||
|
// flag was consumed so a subsequent user abort still gets the reset path
|
||||||
|
expect(deps.internallyAbortedSessions.has(sessionID)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#given an external abort (no internal flag) #when session.error fires with isAbort #then state is still reset as a real cancellation", async () => {
|
||||||
|
// given - regression guard: user-initiated abort path must continue to
|
||||||
|
// wipe state. Only OUR internal aborts get the preservation treatment.
|
||||||
|
const sessionID = "session-external-abort"
|
||||||
|
const deps = createDeps()
|
||||||
|
const abortCalls: string[] = []
|
||||||
|
const clearCalls: string[] = []
|
||||||
|
const state = createFallbackState("opencode-go/glm-5.1")
|
||||||
|
state.currentModel = "github-copilot/claude-haiku-4.5"
|
||||||
|
state.attemptCount = 1
|
||||||
|
deps.sessionStates.set(sessionID, state)
|
||||||
|
// NB: internallyAbortedSessions is empty
|
||||||
|
const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls))
|
||||||
|
|
||||||
|
// when
|
||||||
|
await handler({ event: { type: "session.error", properties: { sessionID, error: { name: "MessageAbortedError" } } } })
|
||||||
|
|
||||||
|
// then - state reset, behaviour matches pre-fix cancellation path
|
||||||
|
const reset = deps.sessionStates.get(sessionID)
|
||||||
|
expect(reset?.attemptCount).toBe(0)
|
||||||
|
expect(reset?.currentModel).toBe("opencode-go/glm-5.1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#given two consecutive internal-abort cycles #when session.error fires each time #then attemptCount can progress past 1", async () => {
|
||||||
|
// given - the failure mode in issue #4006 manifested as attempt:1 looping
|
||||||
|
// forever because every cycle reset attemptCount. This test verifies the
|
||||||
|
// counter actually advances when the internal-abort flag is honored
|
||||||
|
// across multiple iterations.
|
||||||
|
const sessionID = "session-progressing-attempts"
|
||||||
|
const deps = createDeps()
|
||||||
|
const abortCalls: string[] = []
|
||||||
|
const clearCalls: string[] = []
|
||||||
|
const state = createFallbackState("opencode-go/glm-5.1")
|
||||||
|
state.attemptCount = 1
|
||||||
|
state.pendingFallbackModel = "github-copilot/claude-haiku-4.5"
|
||||||
|
deps.sessionStates.set(sessionID, state)
|
||||||
|
const handler = createEventHandler(deps, createHelpers(deps, abortCalls, clearCalls))
|
||||||
|
|
||||||
|
// iteration 1: internal abort -> session.error{isAbort:true}
|
||||||
|
deps.internallyAbortedSessions.add(sessionID)
|
||||||
|
await handler({ event: { type: "session.error", properties: { sessionID, error: { name: "MessageAbortedError" } } } })
|
||||||
|
expect(deps.sessionStates.get(sessionID)?.attemptCount).toBe(1)
|
||||||
|
|
||||||
|
// simulate the next retry signal advancing the counter
|
||||||
|
const advanced = deps.sessionStates.get(sessionID)!
|
||||||
|
advanced.attemptCount = 2
|
||||||
|
|
||||||
|
// iteration 2: another internal abort
|
||||||
|
deps.internallyAbortedSessions.add(sessionID)
|
||||||
|
await handler({ event: { type: "session.error", properties: { sessionID, error: { name: "MessageAbortedError" } } } })
|
||||||
|
|
||||||
|
// then - counter is at 2, not reset to 0
|
||||||
|
expect(deps.sessionStates.get(sessionID)?.attemptCount).toBe(2)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -138,6 +138,14 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
|
|||||||
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
|
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
|
||||||
|
|
||||||
if (isAbortError(error)) {
|
if (isAbortError(error)) {
|
||||||
|
// If we triggered this abort to swap in a fallback model, consume the
|
||||||
|
// flag and preserve state — wiping attemptCount here is what causes
|
||||||
|
// the infinite retry loop (issue #4006).
|
||||||
|
if (deps.internallyAbortedSessions.has(sessionID)) {
|
||||||
|
deps.internallyAbortedSessions.delete(sessionID)
|
||||||
|
log(`[${HOOK_NAME}] session.error matched internal abort; preserving retry state`, { sessionID, resolvedAgent })
|
||||||
|
return
|
||||||
|
}
|
||||||
cancelledSessions.add(sessionID)
|
cancelledSessions.add(sessionID)
|
||||||
resetRetryState(sessionID)
|
resetRetryState(sessionID)
|
||||||
log(`[${HOOK_NAME}] session.error matched cancellation; cleared retry state`, { sessionID, resolvedAgent })
|
log(`[${HOOK_NAME}] session.error matched cancellation; cleared retry state`, { sessionID, resolvedAgent })
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function createRuntimeFallbackHook(
|
|||||||
sessionAwaitingFallbackResult: new Set(),
|
sessionAwaitingFallbackResult: new Set(),
|
||||||
sessionFallbackTimeouts: new Map(),
|
sessionFallbackTimeouts: new Map(),
|
||||||
sessionStatusRetryKeys: new Map(),
|
sessionStatusRetryKeys: new Map(),
|
||||||
|
internallyAbortedSessions: new Set(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const helpers = createAutoRetryHelpers(deps)
|
const helpers = createAutoRetryHelpers(deps)
|
||||||
@@ -81,6 +82,7 @@ export function createRuntimeFallbackHook(
|
|||||||
deps.sessionAwaitingFallbackResult.clear()
|
deps.sessionAwaitingFallbackResult.clear()
|
||||||
deps.sessionFallbackTimeouts.clear()
|
deps.sessionFallbackTimeouts.clear()
|
||||||
deps.sessionStatusRetryKeys.clear()
|
deps.sessionStatusRetryKeys.clear()
|
||||||
|
deps.internallyAbortedSessions.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -74,4 +74,12 @@ export interface HookDeps {
|
|||||||
sessionAwaitingFallbackResult: Set<string>
|
sessionAwaitingFallbackResult: Set<string>
|
||||||
sessionFallbackTimeouts: Map<string, RuntimeFallbackTimeout>
|
sessionFallbackTimeouts: Map<string, RuntimeFallbackTimeout>
|
||||||
sessionStatusRetryKeys: Map<string, string>
|
sessionStatusRetryKeys: Map<string, string>
|
||||||
|
/**
|
||||||
|
* Sessions whose in-flight request was aborted by us (to swap in a fallback
|
||||||
|
* model), as opposed to a user-initiated stop. Consumed by
|
||||||
|
* handleSessionError so the resulting session.error{isAbort:true} does NOT
|
||||||
|
* reset attemptCount — that reset is what was driving the infinite retry
|
||||||
|
* loop (every cycle started over at attempt:1). See issue #4006.
|
||||||
|
*/
|
||||||
|
internallyAbortedSessions: Set<string>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user