Skip to content

Commit 365bfa1

Browse files
committed
fix: fallback on safe prompt timeouts
1 parent b8811b7 commit 365bfa1

4 files changed

Lines changed: 154 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Coverage for handing replay-safe plugin-harness prompt timeouts to model fallback.
2+
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
3+
import { makeModelFallbackCfg } from "../test-helpers/model-fallback-config-fixture.js";
4+
import { makeAttemptResult } from "./run.overflow-compaction.fixture.js";
5+
import {
6+
loadRunOverflowCompactionHarness,
7+
MockedFailoverError,
8+
mockedClassifyFailoverReason,
9+
mockedRunEmbeddedAttempt,
10+
overflowBaseRunParams,
11+
resetRunOverflowCompactionHarnessMocks,
12+
} from "./run.overflow-compaction.harness.js";
13+
14+
let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent;
15+
16+
describe("runEmbeddedAgent prompt timeout fallback handoff", () => {
17+
beforeAll(async () => {
18+
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
19+
});
20+
21+
beforeEach(() => {
22+
resetRunOverflowCompactionHarnessMocks();
23+
});
24+
25+
it("throws FailoverError for replay-safe harness-owned prompt timeouts when model fallbacks are configured", async () => {
26+
mockedClassifyFailoverReason.mockReturnValue("timeout");
27+
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
28+
makeAttemptResult({
29+
assistantTexts: [],
30+
promptError: new Error("LLM request timed out."),
31+
promptErrorSource: "prompt",
32+
}),
33+
);
34+
35+
const promise = runEmbeddedAgent({
36+
...overflowBaseRunParams,
37+
provider: "openai",
38+
model: "gpt-5.4",
39+
runId: "run-prompt-timeout-fallback",
40+
config: makeModelFallbackCfg({
41+
agents: {
42+
defaults: {
43+
model: {
44+
primary: "openai/gpt-5.4",
45+
fallbacks: ["anthropic/claude-opus-4-6"],
46+
},
47+
},
48+
},
49+
}),
50+
});
51+
52+
await expect(promise).rejects.toBeInstanceOf(MockedFailoverError);
53+
await expect(promise).rejects.toThrow("LLM request timed out.");
54+
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
55+
});
56+
57+
it("surfaces replay-invalid prompt timeouts instead of handing them to model fallback", async () => {
58+
mockedClassifyFailoverReason.mockReturnValue("timeout");
59+
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
60+
makeAttemptResult({
61+
assistantTexts: [],
62+
promptError: new Error("LLM request timed out."),
63+
promptErrorSource: "prompt",
64+
promptTimeoutOutcome: {
65+
message: "Harness abandoned the timed-out turn after provider activity.",
66+
replayInvalid: true,
67+
livenessState: "abandoned",
68+
},
69+
}),
70+
);
71+
72+
let thrown: unknown;
73+
try {
74+
await runEmbeddedAgent({
75+
...overflowBaseRunParams,
76+
provider: "openai",
77+
model: "gpt-5.4",
78+
runId: "run-prompt-timeout-replay-invalid",
79+
config: makeModelFallbackCfg({
80+
agents: {
81+
defaults: {
82+
model: {
83+
primary: "openai/gpt-5.4",
84+
fallbacks: ["anthropic/claude-opus-4-6"],
85+
},
86+
},
87+
},
88+
}),
89+
});
90+
} catch (err) {
91+
thrown = err;
92+
}
93+
94+
expect(thrown).toBeInstanceOf(Error);
95+
expect(thrown).not.toBeInstanceOf(MockedFailoverError);
96+
expect(String((thrown as Error | undefined)?.message)).toContain("LLM request timed out.");
97+
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
98+
});
99+
});

src/agents/embedded-agent-runner/run.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3103,6 +3103,12 @@ async function runEmbeddedAgentInternal(
31033103
);
31043104
const promptFailoverFailure =
31053105
promptFailoverReason !== null || isFailoverErrorMessage(errorText, { provider });
3106+
const promptTimeoutFallbackSafe =
3107+
promptErrorSource === "prompt" &&
3108+
promptFailoverReason === "timeout" &&
3109+
!attempt.codexAppServerFailure &&
3110+
attempt.promptTimeoutOutcome?.replayInvalid !== true &&
3111+
attempt.replayMetadata.replaySafe;
31063112
// Capture the failing profile before auth-profile rotation mutates `lastProfileId`.
31073113
const failedPromptProfileId = lastProfileId;
31083114
const logPromptFailoverDecision = createFailoverDecisionLogger({
@@ -3134,6 +3140,7 @@ async function runEmbeddedAgentInternal(
31343140
failoverFailure: promptFailoverFailure,
31353141
failoverReason: promptFailoverReason,
31363142
harnessOwnsTransport: pluginHarnessOwnsTransport,
3143+
promptTimeoutFallbackSafe,
31373144
profileRotated: false,
31383145
});
31393146
if (
@@ -3173,6 +3180,7 @@ async function runEmbeddedAgentInternal(
31733180
failoverFailure: promptFailoverFailure,
31743181
failoverReason: promptFailoverReason,
31753182
harnessOwnsTransport: pluginHarnessOwnsTransport,
3183+
promptTimeoutFallbackSafe,
31763184
profileRotated: true,
31773185
});
31783186
}

src/agents/embedded-agent-runner/run/failover-policy.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,44 @@ describe("resolveRunFailoverDecision", () => {
581581
});
582582
});
583583

584+
it("falls back on fallback-safe harness-owned prompt timeouts", () => {
585+
expect(
586+
resolveRunFailoverDecision({
587+
stage: "prompt",
588+
aborted: false,
589+
externalAbort: false,
590+
fallbackConfigured: true,
591+
failoverFailure: true,
592+
failoverReason: "timeout",
593+
harnessOwnsTransport: true,
594+
promptTimeoutFallbackSafe: true,
595+
profileRotated: true,
596+
}),
597+
).toEqual({
598+
action: "fallback_model",
599+
reason: "timeout",
600+
});
601+
});
602+
603+
it("surfaces fallback-safe harness-owned prompt timeouts when no fallback is configured", () => {
604+
expect(
605+
resolveRunFailoverDecision({
606+
stage: "prompt",
607+
aborted: false,
608+
externalAbort: false,
609+
fallbackConfigured: false,
610+
failoverFailure: true,
611+
failoverReason: "timeout",
612+
harnessOwnsTransport: true,
613+
promptTimeoutFallbackSafe: true,
614+
profileRotated: true,
615+
}),
616+
).toEqual({
617+
action: "surface_error",
618+
reason: "timeout",
619+
});
620+
});
621+
584622
it("surfaces error on LLM idle timeout when no fallback is configured and rotation is exhausted", () => {
585623
expect(
586624
resolveRunFailoverDecision({

src/agents/embedded-agent-runner/run/failover-policy.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ type PromptDecisionParams = {
5050
failoverFailure: boolean;
5151
failoverReason: FailoverReason | null;
5252
harnessOwnsTransport?: boolean;
53+
promptTimeoutFallbackSafe?: boolean;
5354
profileRotated: boolean;
5455
};
5556

@@ -179,6 +180,14 @@ export function resolveRunFailoverDecision(params: RunFailoverDecisionParams): R
179180
};
180181
}
181182
if (params.harnessOwnsTransport && params.failoverReason === "timeout") {
183+
// Plugin harness lifecycle timeouts must stay inside the harness boundary;
184+
// only prompt request timeouts proven replay-safe may enter model fallback.
185+
if (params.promptTimeoutFallbackSafe === true && params.fallbackConfigured) {
186+
return {
187+
action: "fallback_model",
188+
reason: "timeout",
189+
};
190+
}
182191
return {
183192
action: "surface_error",
184193
reason: params.failoverReason,

0 commit comments

Comments
 (0)