fix(runtime-fallback): fall back to synthetic continuation when session messages are empty (#3645)
When the working directory contains a .git folder the OpenCode server
normalises the project root to the git root before persisting messages.
This creates a race: the 429/503/529 error event can fire before the
user's message is committed to storage, so session.messages returns []
and getLastUserRetryParts returns an empty array. The previous code
treated that as a silent no-op (cleared all retry state, Sisyphus stalled).
Fix: when fetchedParts is empty, emit a structured log explaining the
.git-directory race and fall back to a synthetic { type:"text", text:"continue" }
part — matching the pattern already used by autoContinueAfterFallback in
event.ts. The fallback dispatch always proceeds regardless of whether
the messages API can return user parts.
Update four tests that fired two consecutive session.error events relying
on the old silent-stop behaviour: add top-level model fields to the second
error so the awaiting-fallback gate recognises it as coming from the
dispatched fallback model and lets it through normally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -155,68 +155,77 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
const retryPayload = getLastUserRetryPayload(messagesResp, sessionID)
|
||||
const retryParts = retryPayload.retryParts
|
||||
if (retryParts.length > 0) {
|
||||
log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, {
|
||||
sessionID,
|
||||
model: newModel,
|
||||
})
|
||||
const fetchedParts = retryPayload.retryParts
|
||||
const retryParts =
|
||||
fetchedParts.length > 0
|
||||
? fetchedParts
|
||||
: (() => {
|
||||
log(
|
||||
`[${HOOK_NAME}] No user message parts found for auto-retry (${source}); using synthetic continuation`,
|
||||
{
|
||||
sessionID,
|
||||
hint: "This can occur when the working directory contains .git and messages are not yet persisted",
|
||||
},
|
||||
)
|
||||
return [{ type: "text" as const, text: "continue" }]
|
||||
})()
|
||||
log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, {
|
||||
sessionID,
|
||||
model: newModel,
|
||||
})
|
||||
|
||||
const retryAgent = resolvedAgent ?? getSessionAgent(sessionID)
|
||||
const launchAgent = resolveRegisteredAgentName(retryAgent)
|
||||
if (!hadAwaitingFallbackResult) {
|
||||
sessionAwaitingFallbackResult.add(sessionID)
|
||||
scheduleSessionFallbackTimeout(sessionID, retryAgent)
|
||||
}
|
||||
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: `runtime-fallback:${source}`,
|
||||
settleMs: 0,
|
||||
queueBehavior: "defer",
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
...(launchAgent ? { agent: launchAgent } : {}),
|
||||
...retryModelPayload,
|
||||
...(retryPayload.system ? { system: retryPayload.system } : {}),
|
||||
...(retryPayload.tools ? { tools: retryPayload.tools } : {}),
|
||||
parts: retryParts,
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
},
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
|
||||
retryMayHaveBeenAccepted = true
|
||||
log(`[${HOOK_NAME}] Auto-retry prompt failed after dispatch may have been accepted (${source}); preserving fallback state`, {
|
||||
sessionID,
|
||||
error: String(promptResult.error),
|
||||
})
|
||||
}
|
||||
throw promptResult.error
|
||||
}
|
||||
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||
log(`[${HOOK_NAME}] Auto-retry skipped by promptAsync gate (${source})`, {
|
||||
sessionID,
|
||||
status: promptResult.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
const retryAgent = resolvedAgent ?? getSessionAgent(sessionID)
|
||||
const launchAgent = resolveRegisteredAgentName(retryAgent)
|
||||
if (!hadAwaitingFallbackResult) {
|
||||
sessionAwaitingFallbackResult.add(sessionID)
|
||||
if (hadAwaitingFallbackResult) {
|
||||
scheduleSessionFallbackTimeout(sessionID, retryAgent)
|
||||
}
|
||||
const state = sessionStates.get(sessionID)
|
||||
if (state) {
|
||||
state.pendingFallbackPromptMayHaveBeenAccepted = false
|
||||
}
|
||||
retryDispatched = true
|
||||
} else {
|
||||
log(`[${HOOK_NAME}] No user message found for auto-retry (${source})`, { sessionID })
|
||||
scheduleSessionFallbackTimeout(sessionID, retryAgent)
|
||||
}
|
||||
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: `runtime-fallback:${source}`,
|
||||
settleMs: 0,
|
||||
queueBehavior: "defer",
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
...(launchAgent ? { agent: launchAgent } : {}),
|
||||
...retryModelPayload,
|
||||
...(retryPayload.system ? { system: retryPayload.system } : {}),
|
||||
...(retryPayload.tools ? { tools: retryPayload.tools } : {}),
|
||||
parts: retryParts,
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
},
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
if (isAmbiguousPostDispatchPromptFailure(promptResult)) {
|
||||
retryMayHaveBeenAccepted = true
|
||||
log(`[${HOOK_NAME}] Auto-retry prompt failed after dispatch may have been accepted (${source}); preserving fallback state`, {
|
||||
sessionID,
|
||||
error: String(promptResult.error),
|
||||
})
|
||||
}
|
||||
throw promptResult.error
|
||||
}
|
||||
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||
log(`[${HOOK_NAME}] Auto-retry skipped by promptAsync gate (${source})`, {
|
||||
sessionID,
|
||||
status: promptResult.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
sessionAwaitingFallbackResult.add(sessionID)
|
||||
if (hadAwaitingFallbackResult) {
|
||||
scheduleSessionFallbackTimeout(sessionID, retryAgent)
|
||||
}
|
||||
const state = sessionStates.get(sessionID)
|
||||
if (state) {
|
||||
state.pendingFallbackPromptMayHaveBeenAccepted = false
|
||||
}
|
||||
retryDispatched = true
|
||||
} catch (retryError) {
|
||||
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
|
||||
} finally {
|
||||
|
||||
@@ -380,6 +380,9 @@ describe("runtime-fallback", () => {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
// model at the top level so the awaiting-fallback gate recognises this
|
||||
// as an error from the fallback model we just dispatched
|
||||
model: "anthropic/claude-opus-4.7",
|
||||
error: { name: "UnknownError", data: { message: "Model not found: anthropic/claude-opus-4.7." } },
|
||||
},
|
||||
},
|
||||
@@ -432,6 +435,9 @@ describe("runtime-fallback", () => {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
// model at the top level so the awaiting-fallback gate recognises this
|
||||
// as an error from the fallback model we just dispatched
|
||||
model: "anthropic/claude-opus-4.7",
|
||||
error: {
|
||||
name: "ProviderModelNotFoundError",
|
||||
data: {
|
||||
@@ -2764,11 +2770,15 @@ describe("runtime-fallback", () => {
|
||||
},
|
||||
})
|
||||
|
||||
// Simulate the fallback session completing before the next error arrives
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
|
||||
//#when - second error occurs immediately; tries to switch back to original model but should be in cooldown
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { statusCode: 429 } },
|
||||
// model matches pendingFallbackModel so the awaiting-fallback gate lets this through
|
||||
properties: { sessionID, model: "openai/gpt-5.4", error: { statusCode: 429 } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3158,14 +3168,21 @@ describe("runtime-fallback", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const autoRetryLog = logCalls.find((call) => call.msg.includes("No user message found for auto-retry"))
|
||||
const autoRetryLog = logCalls.find((call) =>
|
||||
call.msg.includes("No user message parts found for auto-retry") &&
|
||||
call.msg.includes("using synthetic continuation"),
|
||||
)
|
||||
expect(autoRetryLog).toBeDefined()
|
||||
|
||||
// Simulate the fallback session completing before the next error arrives
|
||||
await hook.event({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
|
||||
//#when - second error fires after retry completed (retryInFlight cleared)
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { statusCode: 429, message: "Rate limit again" } },
|
||||
// model matches pendingFallbackModel so the awaiting-fallback gate lets this through
|
||||
properties: { sessionID, model: "provider-a/model-a", error: { statusCode: 429, message: "Rate limit again" } },
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user