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:
@@ -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