fix: fallback on safe prompt timeouts (#96142)

(cherry picked from commit 0da26499da)
This commit is contained in:
brokemac79
2026-06-26 00:33:44 +01:00
committed by Dallin Romney
parent 23f1f130f8
commit 3aa5229691
4 changed files with 154 additions and 0 deletions
@@ -0,0 +1,99 @@
// Coverage for handing replay-safe plugin-harness prompt timeouts to model fallback.
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { makeModelFallbackCfg } from "../test-helpers/model-fallback-config-fixture.js";
import { makeAttemptResult } from "./run.overflow-compaction.fixture.js";
import {
loadRunOverflowCompactionHarness,
MockedFailoverError,
mockedClassifyFailoverReason,
mockedRunEmbeddedAttempt,
overflowBaseRunParams,
resetRunOverflowCompactionHarnessMocks,
} from "./run.overflow-compaction.harness.js";
let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent;
describe("runEmbeddedAgent prompt timeout fallback handoff", () => {
beforeAll(async () => {
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
});
beforeEach(() => {
resetRunOverflowCompactionHarnessMocks();
});
it("throws FailoverError for replay-safe harness-owned prompt timeouts when model fallbacks are configured", async () => {
mockedClassifyFailoverReason.mockReturnValue("timeout");
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({
assistantTexts: [],
promptError: new Error("LLM request timed out."),
promptErrorSource: "prompt",
}),
);
const promise = runEmbeddedAgent({
...overflowBaseRunParams,
provider: "openai",
model: "gpt-5.4",
runId: "run-prompt-timeout-fallback",
config: makeModelFallbackCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-5.4",
fallbacks: ["anthropic/claude-opus-4-6"],
},
},
},
}),
});
await expect(promise).rejects.toBeInstanceOf(MockedFailoverError);
await expect(promise).rejects.toThrow("LLM request timed out.");
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
});
it("surfaces replay-invalid prompt timeouts instead of handing them to model fallback", async () => {
mockedClassifyFailoverReason.mockReturnValue("timeout");
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({
assistantTexts: [],
promptError: new Error("LLM request timed out."),
promptErrorSource: "prompt",
promptTimeoutOutcome: {
message: "Harness abandoned the timed-out turn after provider activity.",
replayInvalid: true,
livenessState: "abandoned",
},
}),
);
let thrown: unknown;
try {
await runEmbeddedAgent({
...overflowBaseRunParams,
provider: "openai",
model: "gpt-5.4",
runId: "run-prompt-timeout-replay-invalid",
config: makeModelFallbackCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-5.4",
fallbacks: ["anthropic/claude-opus-4-6"],
},
},
},
}),
});
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(Error);
expect(thrown).not.toBeInstanceOf(MockedFailoverError);
expect(String((thrown as Error | undefined)?.message)).toContain("LLM request timed out.");
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
});
});
+8
View File
@@ -3116,6 +3116,12 @@ async function runEmbeddedAgentInternal(
);
const promptFailoverFailure =
promptFailoverReason !== null || isFailoverErrorMessage(errorText, { provider });
const promptTimeoutFallbackSafe =
promptErrorSource === "prompt" &&
promptFailoverReason === "timeout" &&
!attempt.codexAppServerFailure &&
attempt.promptTimeoutOutcome?.replayInvalid !== true &&
attempt.replayMetadata.replaySafe;
// Capture the failing profile before auth-profile rotation mutates `lastProfileId`.
const failedPromptProfileId = lastProfileId;
const logPromptFailoverDecision = createFailoverDecisionLogger({
@@ -3147,6 +3153,7 @@ async function runEmbeddedAgentInternal(
failoverFailure: promptFailoverFailure,
failoverReason: promptFailoverReason,
harnessOwnsTransport: pluginHarnessOwnsTransport,
promptTimeoutFallbackSafe,
profileRotated: false,
});
if (
@@ -3186,6 +3193,7 @@ async function runEmbeddedAgentInternal(
failoverFailure: promptFailoverFailure,
failoverReason: promptFailoverReason,
harnessOwnsTransport: pluginHarnessOwnsTransport,
promptTimeoutFallbackSafe,
profileRotated: true,
});
}
@@ -581,6 +581,44 @@ describe("resolveRunFailoverDecision", () => {
});
});
it("falls back on fallback-safe harness-owned prompt timeouts", () => {
expect(
resolveRunFailoverDecision({
stage: "prompt",
aborted: false,
externalAbort: false,
fallbackConfigured: true,
failoverFailure: true,
failoverReason: "timeout",
harnessOwnsTransport: true,
promptTimeoutFallbackSafe: true,
profileRotated: true,
}),
).toEqual({
action: "fallback_model",
reason: "timeout",
});
});
it("surfaces fallback-safe harness-owned prompt timeouts when no fallback is configured", () => {
expect(
resolveRunFailoverDecision({
stage: "prompt",
aborted: false,
externalAbort: false,
fallbackConfigured: false,
failoverFailure: true,
failoverReason: "timeout",
harnessOwnsTransport: true,
promptTimeoutFallbackSafe: true,
profileRotated: true,
}),
).toEqual({
action: "surface_error",
reason: "timeout",
});
});
it("surfaces error on LLM idle timeout when no fallback is configured and rotation is exhausted", () => {
expect(
resolveRunFailoverDecision({
@@ -50,6 +50,7 @@ type PromptDecisionParams = {
failoverFailure: boolean;
failoverReason: FailoverReason | null;
harnessOwnsTransport?: boolean;
promptTimeoutFallbackSafe?: boolean;
profileRotated: boolean;
};
@@ -179,6 +180,14 @@ export function resolveRunFailoverDecision(params: RunFailoverDecisionParams): R
};
}
if (params.harnessOwnsTransport && params.failoverReason === "timeout") {
// Plugin harness lifecycle timeouts must stay inside the harness boundary;
// only prompt request timeouts proven replay-safe may enter model fallback.
if (params.promptTimeoutFallbackSafe === true && params.fallbackConfigured) {
return {
action: "fallback_model",
reason: "timeout",
};
}
return {
action: "surface_error",
reason: params.failoverReason,