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 },
|
query: { directory: ctx.directory },
|
||||||
})
|
})
|
||||||
const retryPayload = getLastUserRetryPayload(messagesResp, sessionID)
|
const retryPayload = getLastUserRetryPayload(messagesResp, sessionID)
|
||||||
const retryParts = retryPayload.retryParts
|
const fetchedParts = retryPayload.retryParts
|
||||||
if (retryParts.length > 0) {
|
const retryParts =
|
||||||
log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, {
|
fetchedParts.length > 0
|
||||||
sessionID,
|
? fetchedParts
|
||||||
model: newModel,
|
: (() => {
|
||||||
})
|
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 retryAgent = resolvedAgent ?? getSessionAgent(sessionID)
|
||||||
const launchAgent = resolveRegisteredAgentName(retryAgent)
|
const launchAgent = resolveRegisteredAgentName(retryAgent)
|
||||||
if (!hadAwaitingFallbackResult) {
|
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
|
|
||||||
}
|
|
||||||
sessionAwaitingFallbackResult.add(sessionID)
|
sessionAwaitingFallbackResult.add(sessionID)
|
||||||
if (hadAwaitingFallbackResult) {
|
scheduleSessionFallbackTimeout(sessionID, retryAgent)
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (retryError) {
|
||||||
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
|
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -380,6 +380,9 @@ describe("runtime-fallback", () => {
|
|||||||
type: "session.error",
|
type: "session.error",
|
||||||
properties: {
|
properties: {
|
||||||
sessionID,
|
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." } },
|
error: { name: "UnknownError", data: { message: "Model not found: anthropic/claude-opus-4.7." } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -432,6 +435,9 @@ describe("runtime-fallback", () => {
|
|||||||
type: "session.error",
|
type: "session.error",
|
||||||
properties: {
|
properties: {
|
||||||
sessionID,
|
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: {
|
error: {
|
||||||
name: "ProviderModelNotFoundError",
|
name: "ProviderModelNotFoundError",
|
||||||
data: {
|
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
|
//#when - second error occurs immediately; tries to switch back to original model but should be in cooldown
|
||||||
await hook.event({
|
await hook.event({
|
||||||
event: {
|
event: {
|
||||||
type: "session.error",
|
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()
|
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)
|
//#when - second error fires after retry completed (retryInFlight cleared)
|
||||||
await hook.event({
|
await hook.event({
|
||||||
event: {
|
event: {
|
||||||
type: "session.error",
|
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