Skip to content

Commit 9d56f4a

Browse files
samzongsteipete
authored andcommitted
fix(gateway): preserve deferred lifecycle errors
1 parent 4cc2b29 commit 9d56f4a

2 files changed

Lines changed: 129 additions & 10 deletions

File tree

src/gateway/server-chat.agent-events.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2110,6 +2110,117 @@ describe("agent event handler", () => {
21102110
expect(agentRunSeq.has("run-terminal-error")).toBe(false);
21112111
});
21122112

2113+
it("keeps deferred lifecycle-error cleanup across later non-terminal events", () => {
2114+
vi.useFakeTimers();
2115+
const { broadcast, clearAgentRunContext, agentRunSeq, handler } = createHarness({
2116+
resolveSessionKeyForRun: () => "session-terminal-error",
2117+
lifecycleErrorRetryGraceMs: 100,
2118+
});
2119+
registerAgentRunContext("run-terminal-late-tool", {
2120+
sessionKey: "session-terminal-error",
2121+
});
2122+
2123+
handler({
2124+
runId: "run-terminal-late-tool",
2125+
seq: 1,
2126+
stream: "lifecycle",
2127+
ts: Date.now(),
2128+
data: { phase: "start" },
2129+
});
2130+
handler({
2131+
runId: "run-terminal-late-tool",
2132+
seq: 2,
2133+
stream: "lifecycle",
2134+
ts: Date.now(),
2135+
data: { phase: "error", error: "request timed out" },
2136+
});
2137+
handler({
2138+
runId: "run-terminal-late-tool",
2139+
seq: 3,
2140+
stream: "tool",
2141+
ts: Date.now(),
2142+
data: { phase: "result", name: "exec" },
2143+
});
2144+
2145+
vi.advanceTimersByTime(99);
2146+
2147+
expect(clearAgentRunContext).not.toHaveBeenCalled();
2148+
expect(agentRunSeq.get("run-terminal-late-tool")).toBe(3);
2149+
expect(
2150+
chatBroadcastCalls(broadcast).some(
2151+
([, payload]) => (payload as { state?: string }).state === "error",
2152+
),
2153+
).toBe(false);
2154+
2155+
vi.advanceTimersByTime(1);
2156+
2157+
const finalPayload = chatBroadcastCalls(broadcast).at(-1)?.[1] as {
2158+
state?: string;
2159+
runId?: string;
2160+
errorMessage?: string;
2161+
};
2162+
expect(finalPayload.state).toBe("error");
2163+
expect(finalPayload.runId).toBe("run-terminal-late-tool");
2164+
expect(finalPayload.errorMessage).toContain("request timed out");
2165+
expect(clearAgentRunContext).toHaveBeenCalledWith("run-terminal-late-tool");
2166+
expect(agentRunSeq.has("run-terminal-late-tool")).toBe(false);
2167+
expect(
2168+
persistGatewaySessionLifecycleEventMock.mock.calls.some(
2169+
([params]) =>
2170+
(params as { event?: { data?: { phase?: string } } }).event?.data?.phase === "error",
2171+
),
2172+
).toBe(true);
2173+
});
2174+
2175+
it("cancels deferred lifecycle-error cleanup when the run restarts", () => {
2176+
vi.useFakeTimers();
2177+
const { broadcast, clearAgentRunContext, agentRunSeq, handler } = createHarness({
2178+
resolveSessionKeyForRun: () => "session-terminal-retry",
2179+
lifecycleErrorRetryGraceMs: 100,
2180+
});
2181+
registerAgentRunContext("run-terminal-retry", {
2182+
sessionKey: "session-terminal-retry",
2183+
});
2184+
2185+
handler({
2186+
runId: "run-terminal-retry",
2187+
seq: 1,
2188+
stream: "lifecycle",
2189+
ts: Date.now(),
2190+
data: { phase: "start" },
2191+
});
2192+
handler({
2193+
runId: "run-terminal-retry",
2194+
seq: 2,
2195+
stream: "lifecycle",
2196+
ts: Date.now(),
2197+
data: { phase: "error", error: "attempt failed" },
2198+
});
2199+
handler({
2200+
runId: "run-terminal-retry",
2201+
seq: 3,
2202+
stream: "lifecycle",
2203+
ts: Date.now(),
2204+
data: { phase: "start" },
2205+
});
2206+
2207+
vi.advanceTimersByTime(100);
2208+
2209+
expect(
2210+
chatBroadcastCalls(broadcast).some(
2211+
([, payload]) => (payload as { state?: string }).state === "error",
2212+
),
2213+
).toBe(false);
2214+
expect(clearAgentRunContext).not.toHaveBeenCalled();
2215+
expect(agentRunSeq.get("run-terminal-retry")).toBe(3);
2216+
expect(
2217+
persistGatewaySessionLifecycleEventMock.mock.calls.filter(
2218+
([params]) =>
2219+
(params as { event?: { data?: { phase?: string } } }).event?.data?.phase === "error",
2220+
),
2221+
).toHaveLength(0);
2222+
});
2223+
21132224
it("adds detected errorKind to chat lifecycle error payloads", () => {
21142225
const { broadcast, nodeSendToSession, handler } = createHarness({
21152226
resolveSessionKeyForRun: () => "session-detected-error",

src/gateway/server-chat.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,14 @@ export function createAgentEventHandler({
229229
lifecycleErrorRetryGraceMs = AGENT_LIFECYCLE_ERROR_RETRY_GRACE_MS,
230230
isChatSendRunActive = () => false,
231231
}: AgentEventHandlerOptions) {
232-
const pendingTerminalLifecycleErrors = new Map<string, NodeJS.Timeout>();
232+
type TerminalLifecycleOptions = { skipChatErrorFinal?: boolean };
233+
type PendingTerminalLifecycleError = {
234+
timer: NodeJS.Timeout;
235+
event: AgentEventPayload;
236+
opts?: TerminalLifecycleOptions;
237+
};
238+
239+
const pendingTerminalLifecycleErrors = new Map<string, PendingTerminalLifecycleError>();
233240

234241
type AgentTextThrottleStream = "assistant" | "thinking";
235242

@@ -263,7 +270,7 @@ export function createAgentEventHandler({
263270
if (!pending) {
264271
return;
265272
}
266-
clearTimeout(pending);
273+
clearTimeout(pending.timer);
267274
pendingTerminalLifecycleErrors.delete(runId);
268275
};
269276

@@ -362,10 +369,7 @@ export function createAgentEventHandler({
362369
};
363370
};
364371

365-
const finalizeLifecycleEvent = (
366-
evt: AgentEventPayload,
367-
opts?: { skipChatErrorFinal?: boolean },
368-
) => {
372+
const finalizeLifecycleEvent = (evt: AgentEventPayload, opts?: TerminalLifecycleOptions) => {
369373
const lifecyclePhase =
370374
evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null;
371375
if (lifecyclePhase !== "end" && lifecyclePhase !== "error") {
@@ -458,15 +462,19 @@ export function createAgentEventHandler({
458462

459463
const scheduleTerminalLifecycleError = (
460464
evt: AgentEventPayload,
461-
opts?: { skipChatErrorFinal?: boolean },
465+
opts?: TerminalLifecycleOptions,
462466
) => {
463467
clearPendingTerminalLifecycleError(evt.runId);
464468
const timer = setSafeTimeout(() => {
469+
const pending = pendingTerminalLifecycleErrors.get(evt.runId);
470+
if (!pending || pending.timer !== timer) {
471+
return;
472+
}
465473
pendingTerminalLifecycleErrors.delete(evt.runId);
466-
finalizeLifecycleEvent(evt, opts);
474+
finalizeLifecycleEvent(pending.event, pending.opts);
467475
}, lifecycleErrorRetryGraceMs);
468476
timer.unref?.();
469-
pendingTerminalLifecycleErrors.set(evt.runId, timer);
477+
pendingTerminalLifecycleErrors.set(evt.runId, { timer, event: evt, opts });
470478
};
471479

472480
const emitChatDelta = (
@@ -806,7 +814,7 @@ export function createAgentEventHandler({
806814
return (evt: AgentEventPayload) => {
807815
const lifecyclePhase =
808816
evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null;
809-
if (evt.stream !== "lifecycle" || lifecyclePhase !== "error") {
817+
if (lifecyclePhase === "start") {
810818
clearPendingTerminalLifecycleError(evt.runId);
811819
}
812820

0 commit comments

Comments
 (0)