Merge pull request #4072 from PeterPonyu/fix/3645-runtime-fallback-git-silent-fail

fix(runtime-fallback): fall back to synthetic continuation when session messages are empty (#3645)
This commit is contained in:
YeonGyu-Kim
2026-05-21 12:58:30 +09:00
committed by GitHub
2 changed files with 88 additions and 62 deletions
+68 -59
View File
@@ -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 {
+20 -3
View File
@@ -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" } },
},
})