Skip to content

Commit 51b4503

Browse files
committed
fix(gateway): persist webchat dispatch failures
1 parent 96e27c6 commit 51b4503

2 files changed

Lines changed: 136 additions & 4 deletions

File tree

src/gateway/server-methods/chat.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ import { ADMIN_SCOPE } from "../method-scopes.js";
154154
import { chatAbortMarkerTimestampMs, type ChatRunTiming } from "../server-chat-state.js";
155155
import { getMaxChatHistoryMessagesBytes, MAX_PAYLOAD_BYTES } from "../server-constants.js";
156156
import { resolveSessionHistoryTailReadOptions } from "../session-history-state.js";
157+
import { persistGatewaySessionLifecycleEvent } from "../session-lifecycle-state.js";
157158
import { readSessionTranscriptIndex } from "../session-transcript-index.fs.js";
158159
import {
159160
capArrayByJsonBytes,
@@ -3725,6 +3726,14 @@ export const chatHandlers: GatewayRequestHandlers = {
37253726
const deliveredReplies: Array<{ payload: ReplyPayload; kind: "block" | "final" }> = [];
37263727
let appendedWebchatAgentMedia = false;
37273728
let agentRunStarted = false;
3729+
let pendingDispatchLifecycleError:
3730+
| {
3731+
endedAt: number;
3732+
error: string;
3733+
sessionId: string;
3734+
startedAt: number;
3735+
}
3736+
| undefined;
37283737
const userTurnRecorder: UserTurnTranscriptRecorder = createUserTurnTranscriptRecorder({
37293738
input: baseUserTurnInput,
37303739
resolveInput: () => userTurnInputPromise,
@@ -4934,6 +4943,7 @@ export const chatHandlers: GatewayRequestHandlers = {
49344943
);
49354944
})
49364945
.catch(async (err: unknown) => {
4946+
const errorMessage = String(err);
49374947
const emitAfterError =
49384948
userTurnRecorder.hasPersisted() || userTurnRecorder.isBlocked()
49394949
? Promise.resolve()
@@ -4943,7 +4953,19 @@ export const chatHandlers: GatewayRequestHandlers = {
49434953
`webchat user transcript update failed after error: ${formatForLog(transcriptErr)}`,
49444954
);
49454955
});
4946-
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
4956+
if (
4957+
!agentRunStarted &&
4958+
!activeRunAbort.controller.signal.aborted &&
4959+
!context.chatAbortedRuns.has(clientRunId)
4960+
) {
4961+
pendingDispatchLifecycleError = {
4962+
endedAt: Date.now(),
4963+
error: errorMessage,
4964+
sessionId: activeRunAbort.entry?.sessionId ?? backingSessionId ?? clientRunId,
4965+
startedAt: activeRunAbort.entry?.startedAtMs ?? now,
4966+
};
4967+
}
4968+
const error = errorShape(ErrorCodes.UNAVAILABLE, errorMessage);
49474969
setGatewayDedupeEntry({
49484970
dedupe: context.dedupe,
49494971
key: `chat:${clientRunId}`,
@@ -4953,7 +4975,7 @@ export const chatHandlers: GatewayRequestHandlers = {
49534975
payload: {
49544976
runId: clientRunId,
49554977
status: "error" as const,
4956-
summary: String(err),
4978+
summary: errorMessage,
49574979
},
49584980
error,
49594981
},
@@ -4963,14 +4985,57 @@ export const chatHandlers: GatewayRequestHandlers = {
49634985
runId: clientRunId,
49644986
sessionKey,
49654987
agentId,
4966-
errorMessage: String(err),
4988+
errorMessage,
49674989
});
49684990
})
4969-
.finally(() => {
4991+
.finally(async () => {
49704992
activeRunAbort.cleanup();
49714993
clearAgentRunContext(clientRunId, lifecycleGeneration);
49724994
clearActiveChatSendDedupeRun(context.dedupe, activeChatSendDedupeKey, clientRunId);
49734995
context.removeChatRun(clientRunId, clientRunId, sessionKey);
4996+
if (!pendingDispatchLifecycleError) {
4997+
return;
4998+
}
4999+
const hasActiveRun = hasTrackedActiveSessionRun({
5000+
context,
5001+
requestedKey: rawSessionKey,
5002+
canonicalKey: sessionKey,
5003+
...(sessionKey === "global" && agentId ? { agentId } : {}),
5004+
defaultAgentId: resolveDefaultAgentId(cfg),
5005+
});
5006+
if (hasActiveRun) {
5007+
return;
5008+
}
5009+
const persisted = await persistGatewaySessionLifecycleEvent({
5010+
sessionKey,
5011+
...(sessionKey === "global" && agentId ? { agentId } : {}),
5012+
event: {
5013+
runId: clientRunId,
5014+
sessionId: pendingDispatchLifecycleError.sessionId,
5015+
lifecycleGeneration,
5016+
ts: pendingDispatchLifecycleError.endedAt,
5017+
data: {
5018+
phase: "error",
5019+
startedAt: pendingDispatchLifecycleError.startedAt,
5020+
endedAt: pendingDispatchLifecycleError.endedAt,
5021+
error: pendingDispatchLifecycleError.error,
5022+
},
5023+
},
5024+
})
5025+
.then(() => true)
5026+
.catch((persistErr) => {
5027+
context.logGateway.warn(
5028+
`webchat session lifecycle persist failed after error: ${formatForLog(persistErr)}`,
5029+
);
5030+
return false;
5031+
});
5032+
if (persisted) {
5033+
emitSessionsChanged(context, {
5034+
sessionKey,
5035+
...(agentId ? { agentId } : {}),
5036+
reason: "chat.dispatch-error",
5037+
});
5038+
}
49745039
});
49755040
} catch (err) {
49765041
activeRunAbort.cleanup({ force: true });

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,73 @@ describe("gateway server chat", () => {
692692
});
693693
});
694694

695+
test("marks a running webchat session failed when dispatch rejects before a reply", async () => {
696+
await withMainSessionStore(async (dir) => {
697+
await writeSessionStore({
698+
entries: {
699+
main: {
700+
sessionId: "sess-main",
701+
sessionFile: path.join(dir, "sess-main.jsonl"),
702+
updatedAt: 1_000,
703+
status: "running",
704+
startedAt: 900,
705+
},
706+
},
707+
});
708+
const subscribeRes = await rpcReq(ws, "sessions.subscribe", {});
709+
expect(subscribeRes.ok).toBe(true);
710+
dispatchInboundMessageMock.mockRejectedValueOnce(new Error("provider rejected request"));
711+
712+
const errorPromise = onceMessage(
713+
ws,
714+
(o) =>
715+
o.type === "event" &&
716+
o.event === "chat" &&
717+
o.payload?.state === "error" &&
718+
o.payload?.runId === "idem-dispatch-error-1",
719+
8_000,
720+
);
721+
const sessionChangedPromise = onceMessage(
722+
ws,
723+
(o) =>
724+
o.type === "event" &&
725+
o.event === "sessions.changed" &&
726+
o.payload?.reason === "chat.dispatch-error" &&
727+
o.payload?.sessionKey === "agent:main:main",
728+
8_000,
729+
);
730+
const res = await rpcReq(ws, "chat.send", {
731+
sessionKey: "main",
732+
message: "run: pwd",
733+
idempotencyKey: "idem-dispatch-error-1",
734+
});
735+
expect(res.ok).toBe(true);
736+
await errorPromise;
737+
const sessionChanged = await sessionChangedPromise;
738+
expectRecordFields(sessionChanged.payload, {
739+
sessionId: "sess-main",
740+
status: "failed",
741+
hasActiveRun: false,
742+
});
743+
744+
const sessionsRes = await rpcReq<{ sessions?: unknown[] }>(ws, "sessions.list", {});
745+
expect(sessionsRes.ok).toBe(true);
746+
const session = sessionsRes.payload?.sessions?.find(
747+
(row): row is Record<string, unknown> =>
748+
Boolean(row) &&
749+
typeof row === "object" &&
750+
(row as { key?: unknown }).key === "agent:main:main",
751+
);
752+
const actualSession = expectRecordFields(session, {
753+
status: "failed",
754+
hasActiveRun: false,
755+
});
756+
expect(typeof actualSession.startedAt).toBe("number");
757+
expect(typeof actualSession.endedAt).toBe("number");
758+
expect(typeof actualSession.runtimeMs).toBe("number");
759+
});
760+
});
761+
695762
test("chat.history hides assistant NO_REPLY-only entries", async () => {
696763
const historyMessages = await loadChatHistoryWithMessages(buildNoReplyHistoryFixture());
697764
const textValues = collectHistoryTextValues(historyMessages);

0 commit comments

Comments
 (0)