Skip to content

Commit 92c10d4

Browse files
authored
Fix WebChat dispatch failure session status (#84352)
Merged via squash. Prepared head SHA: 562f2ac Co-authored-by: jesse-merhi <[email protected]> Reviewed-by: @jesse-merhi
1 parent b22ae2a commit 92c10d4

2 files changed

Lines changed: 139 additions & 3 deletions

File tree

src/gateway/server-methods/chat.ts

Lines changed: 72 additions & 3 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,
@@ -3746,6 +3747,14 @@ export const chatHandlers: GatewayRequestHandlers = {
37463747
const deliveredReplies: Array<{ payload: ReplyPayload; kind: "block" | "final" }> = [];
37473748
let appendedWebchatAgentMedia = false;
37483749
let agentRunStarted = false;
3750+
let pendingDispatchLifecycleError:
3751+
| {
3752+
endedAt: number;
3753+
error: string;
3754+
sessionId: string;
3755+
startedAt: number;
3756+
}
3757+
| undefined;
37493758
const userTurnRecorder: UserTurnTranscriptRecorder = createUserTurnTranscriptRecorder({
37503759
input: baseUserTurnInput,
37513760
resolveInput: () => userTurnInputPromise,
@@ -4957,6 +4966,7 @@ export const chatHandlers: GatewayRequestHandlers = {
49574966
);
49584967
})
49594968
.catch(async (err: unknown) => {
4969+
const errorMessage = String(err);
49604970
const emitAfterError =
49614971
userTurnRecorder.hasPersisted() || userTurnRecorder.isBlocked()
49624972
? Promise.resolve()
@@ -4966,7 +4976,19 @@ export const chatHandlers: GatewayRequestHandlers = {
49664976
`webchat user transcript update failed after error: ${formatForLog(transcriptErr)}`,
49674977
);
49684978
});
4969-
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
4979+
if (
4980+
!agentRunStarted &&
4981+
!activeRunAbort.controller.signal.aborted &&
4982+
!context.chatAbortedRuns.has(clientRunId)
4983+
) {
4984+
pendingDispatchLifecycleError = {
4985+
endedAt: Date.now(),
4986+
error: errorMessage,
4987+
sessionId: activeRunAbort.entry?.sessionId ?? backingSessionId ?? clientRunId,
4988+
startedAt: activeRunAbort.entry?.startedAtMs ?? now,
4989+
};
4990+
}
4991+
const error = errorShape(ErrorCodes.UNAVAILABLE, errorMessage);
49704992
setGatewayDedupeEntry({
49714993
dedupe: context.dedupe,
49724994
key: `chat:${clientRunId}`,
@@ -4976,7 +4998,7 @@ export const chatHandlers: GatewayRequestHandlers = {
49764998
payload: {
49774999
runId: clientRunId,
49785000
status: "error" as const,
4979-
summary: String(err),
5001+
summary: errorMessage,
49805002
},
49815003
error,
49825004
},
@@ -4986,14 +5008,61 @@ export const chatHandlers: GatewayRequestHandlers = {
49865008
runId: clientRunId,
49875009
sessionKey,
49885010
agentId,
4989-
errorMessage: String(err),
5011+
errorMessage,
49905012
});
49915013
})
49925014
.finally(() => {
49935015
activeRunAbort.cleanup();
49945016
clearAgentRunContext(clientRunId, lifecycleGeneration);
49955017
clearActiveChatSendDedupeRun(context.dedupe, activeChatSendDedupeKey, clientRunId);
49965018
context.removeChatRun(clientRunId, clientRunId, sessionKey);
5019+
if (!pendingDispatchLifecycleError) {
5020+
return;
5021+
}
5022+
const persistDispatchLifecycleError = async () => {
5023+
const dispatchError = pendingDispatchLifecycleError;
5024+
if (!dispatchError) {
5025+
return;
5026+
}
5027+
const hasActiveRun = hasTrackedActiveSessionRun({
5028+
context,
5029+
requestedKey: rawSessionKey,
5030+
canonicalKey: sessionKey,
5031+
...(sessionKey === "global" && agentId ? { agentId } : {}),
5032+
defaultAgentId: resolveDefaultAgentId(cfg),
5033+
});
5034+
if (hasActiveRun) {
5035+
return;
5036+
}
5037+
try {
5038+
await persistGatewaySessionLifecycleEvent({
5039+
sessionKey,
5040+
...(sessionKey === "global" && agentId ? { agentId } : {}),
5041+
event: {
5042+
runId: clientRunId,
5043+
sessionId: dispatchError.sessionId,
5044+
lifecycleGeneration,
5045+
ts: dispatchError.endedAt,
5046+
data: {
5047+
phase: "error",
5048+
startedAt: dispatchError.startedAt,
5049+
endedAt: dispatchError.endedAt,
5050+
error: dispatchError.error,
5051+
},
5052+
},
5053+
});
5054+
emitSessionsChanged(context, {
5055+
sessionKey,
5056+
...(agentId ? { agentId } : {}),
5057+
reason: "chat.dispatch-error",
5058+
});
5059+
} catch (persistErr: unknown) {
5060+
context.logGateway.warn(
5061+
`webchat session lifecycle persist failed after error: ${formatForLog(persistErr)}`,
5062+
);
5063+
}
5064+
};
5065+
void persistDispatchLifecycleError();
49975066
});
49985067
} catch (err) {
49995068
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)