fix(ai-chat): keep streams alive on silent SSE death + make the stream job idempotent (#22201)
## Problem In production, an AI-chat assistant response sometimes freezes mid-stream (partial text, looks hung), then "picks up again on its own" later without the user resending and without a known worker restart. Root cause: the **agent-chat SSE subscription has no keepalive and no silent-death detection**. - Delivery is fire-and-forget Redis pub/sub (`SubscriptionService.publishToAgentChat`) and the resolver returns the **raw** iterator — unlike `EventStreamResolver`, which heartbeats every 30s via `wrapAsyncIteratorWithLifecycle`. - During a quiet model/tool gap the connection sends no bytes, so a proxy/LB/NAT can silently drop it mid-stream. `graphql-sse` neither surfaces an error nor resumes with `Last-Event-ID`, and **nothing re-pulls the existing Redis chunk catch-up on reconnect** (it only runs on thread (re)mount / `message-persisted` refetch). - So the live view freezes; recovery only happens when the terminal `message-persisted` fires a full refetch from the DB — the observed "self-recovery". This is the **same silent-SSE-death class fixed for the DB event stream in #21061**, which was never applied to the agent-chat path. The symptom also matches #21096 (worker logs the job finishing, client never updates, reload shows the message). It is **not** queue prioritization, and it is **not** addressed by #22193 (which only stabilizes the assistant message id and removes end-of-stream flicker). A secondary, independent self-recovery path also existed: BullMQ stalled-job re-run (default 30s `lockDuration`, no idempotency guard) re-streaming the whole turn → duplicate assistant messages / double billing. ## Changes ### Commit 1 — keepalive + silent-death recovery (ports the #21061 pattern to agent chat) - **Shared:** new `keepalive` variant on `AgentChatSubscriptionEvent`. - **Server:** wrap the agent-chat subscription iterator with `wrapAsyncIteratorWithLifecycle` — emit a `keepalive` on connect and every `APPLICATION_KEEPALIVE_INTERVAL_MS` (30s) so the connection keeps flushing bytes and a dead connection becomes detectable. - **Client:** track the last received event timestamp (refreshed on every chunk/keepalive in the SSE `next` sink); new `AgentChatStreamKeepAliveEffect` forces a resubscribe + messages refetch after 90s of silence, so the durable Redis chunk list backfills the gap (`firstLiveSeq` is reset on resubscribe). ### Commit 2 — stream-job idempotency + lockDuration - Thread a `lockDuration` option through `MessageQueueWorkerOptions` + the BullMQ driver; set `aiStreamQueue` to 10 min so long streams aren't falsely stalled. - Guard `StreamAgentChatJob.handle` with a `streamId`-scoped Redis lock (`SET NX PX` + compare-and-delete release) so a stalled re-run is skipped instead of double-processing. ## Verification ⚠️ I could **not run typecheck/lint locally** — `yarn install` could not complete in this environment (transient registry network aborts before the link step, so `node_modules` never populated). **Please rely on CI for type/lint verification.** The changes are written to match existing conventions; the points most worth a reviewer's eye are the resolver's iterator typing and the ioredis `set(..., 'PX', ttl, 'NX')` overload. How to confirm the root cause in prod: a frozen client with the worker logging `StreamAgentChatJob processed in …ms` and no `[AI_CHAT_NO_TEXT]` is the silent-death signature (check reverse-proxy idle/buffering). For the secondary path, watch `aiStreamQueue` `stalled`/re-processed metrics and duplicate turns around worker restarts. ## Notes / trade-offs - The 10-min `lockDuration` means a genuinely crashed worker's job isn't reclaimed for up to 10 min; the client-side keepalive/catch-up recovers the view independently, and the idempotency lock prevents duplicates. Faster dead-worker recovery could be a follow-up. - Touches `useAgentChatSubscription.ts` / `AgentChatRuntimeEffects.tsx` / `stream-agent-chat.job.ts`, which #22193 also touches — trivial rebase expected. Opened as **draft** pending CI. https://claude.ai/code/session_018dF82A1VcsuWMxPLmdY3dm --- _Generated by [Claude Code](https://claude.ai/code/session_018dF82A1VcsuWMxPLmdY3dm)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22201?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { AgentChatMessagesFetchEffect } from '@/ai/components/AgentChatMessagesFetchEffect';
|
||||
import { AgentChatSessionStartTimeEffect } from '@/ai/components/AgentChatSessionStartTimeEffect';
|
||||
import { AgentChatStreamKeepAliveEffect } from '@/ai/components/AgentChatStreamKeepAliveEffect';
|
||||
import { AgentChatStreamSubscriptionEffect } from '@/ai/components/AgentChatStreamSubscriptionEffect';
|
||||
import { AgentChatStreamingAutoScrollEffect } from '@/ai/components/AgentChatStreamingAutoScrollEffect';
|
||||
import { AgentChatStreamingPartsDiffSyncEffect } from '@/ai/components/AgentChatStreamingPartsDiffSyncEffect';
|
||||
@@ -36,6 +37,7 @@ export const AgentChatRuntimeEffects = () => {
|
||||
<>
|
||||
<AgentChatMessagesFetchEffect />
|
||||
<AgentChatStreamSubscriptionEffect />
|
||||
<AgentChatStreamKeepAliveEffect />
|
||||
<AgentChatSessionStartTimeEffect />
|
||||
{isAgentChatOpen && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
|
||||
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
|
||||
import { AGENT_CHAT_STREAM_LIVENESS_CHECK_INTERVAL_IN_MS } from '@/ai/constants/AgentChatStreamLivenessCheckIntervalInMs';
|
||||
import { AGENT_CHAT_STREAM_LIVENESS_TIMEOUT_IN_MS } from '@/ai/constants/AgentChatStreamLivenessTimeoutInMs';
|
||||
import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState';
|
||||
import { agentChatStreamLastEventTimestampState } from '@/ai/states/agentChatStreamLastEventTimestampState';
|
||||
import { agentChatStreamResubscribeNonceState } from '@/ai/states/agentChatStreamResubscribeNonceState';
|
||||
import { currentAiChatThreadState } from '@/ai/states/currentAiChatThreadState';
|
||||
import { useListenToBrowserEvent } from '@/browser-event/hooks/useListenToBrowserEvent';
|
||||
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
|
||||
import { SSE_CLIENT_RECONNECTED_EVENT_NAME } from '@/sse-db-event/constants/SseClientReconnectedEventName';
|
||||
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
export const AgentChatStreamKeepAliveEffect = () => {
|
||||
const store = useStore();
|
||||
const currentAiChatThread = useAtomStateValue(currentAiChatThreadState);
|
||||
|
||||
const isStreamingFamilyCallback = useAtomComponentFamilyStateCallbackState(
|
||||
agentChatIsStreamingComponentFamilyState,
|
||||
);
|
||||
|
||||
const hasActiveSubscription =
|
||||
isDefined(currentAiChatThread) && isValidUuid(currentAiChatThread);
|
||||
|
||||
const recoverStreamIfStreaming = useCallback(() => {
|
||||
const isStreaming = store.get(
|
||||
isStreamingFamilyCallback({ threadId: currentAiChatThread }),
|
||||
);
|
||||
|
||||
if (!isStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.set(agentChatStreamLastEventTimestampState.atom, Date.now());
|
||||
store.set(agentChatStreamResubscribeNonceState.atom, (nonce) => nonce + 1);
|
||||
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
|
||||
}, [store, isStreamingFamilyCallback, currentAiChatThread]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveSubscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const lastTimestamp = store.get(
|
||||
agentChatStreamLastEventTimestampState.atom,
|
||||
);
|
||||
|
||||
if (!isDefined(lastTimestamp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeSinceLastEventInMs = Date.now() - lastTimestamp;
|
||||
|
||||
if (timeSinceLastEventInMs <= AGENT_CHAT_STREAM_LIVENESS_TIMEOUT_IN_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
recoverStreamIfStreaming();
|
||||
}, AGENT_CHAT_STREAM_LIVENESS_CHECK_INTERVAL_IN_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [hasActiveSubscription, store, recoverStreamIfStreaming]);
|
||||
|
||||
useListenToBrowserEvent({
|
||||
eventName: SSE_CLIENT_RECONNECTED_EVENT_NAME,
|
||||
onBrowserEvent: recoverStreamIfStreaming,
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const AGENT_CHAT_STREAM_LIVENESS_CHECK_INTERVAL_IN_MS = 2_000;
|
||||
@@ -0,0 +1 @@
|
||||
export const AGENT_CHAT_STREAM_LIVENESS_TIMEOUT_IN_MS = 5_000;
|
||||
@@ -18,6 +18,8 @@ import { agentChatHandleEventCallbackComponentFamilyState } from '@/ai/states/ag
|
||||
import { agentChatIsAwaitingPersistedRefetchComponentFamilyState } from '@/ai/states/agentChatIsAwaitingPersistedRefetchComponentFamilyState';
|
||||
import { agentChatIsStreamingComponentFamilyState } from '@/ai/states/agentChatIsStreamingComponentFamilyState';
|
||||
import { agentChatMessagesComponentFamilyState } from '@/ai/states/agentChatMessagesComponentFamilyState';
|
||||
import { agentChatStreamLastEventTimestampState } from '@/ai/states/agentChatStreamLastEventTimestampState';
|
||||
import { agentChatStreamResubscribeNonceState } from '@/ai/states/agentChatStreamResubscribeNonceState';
|
||||
import { agentChatUsageComponentFamilyState } from '@/ai/states/agentChatUsageComponentFamilyState';
|
||||
import { currentAiChatThreadTitleComponentFamilyState } from '@/ai/states/currentAiChatThreadTitleComponentFamilyState';
|
||||
import { AiChatErrorCode } from '@/ai/utils/aiChatErrorCode';
|
||||
@@ -99,6 +101,9 @@ type AgentChatEventPayload = {
|
||||
export const useAgentChatSubscription = (threadId: string | null) => {
|
||||
const store = useStore();
|
||||
const sseClient = useAtomStateValue(sseClientState);
|
||||
const agentChatStreamResubscribeNonce = useAtomStateValue(
|
||||
agentChatStreamResubscribeNonceState,
|
||||
);
|
||||
|
||||
const errorFamilyCallback = useAtomComponentFamilyStateCallbackState(
|
||||
agentChatErrorComponentFamilyState,
|
||||
@@ -152,6 +157,7 @@ export const useAgentChatSubscription = (threadId: string | null) => {
|
||||
let disposed = false;
|
||||
|
||||
store.set(firstLiveSeqAtom, null);
|
||||
store.set(agentChatStreamLastEventTimestampState.atom, Date.now());
|
||||
|
||||
const closeWriter = () => {
|
||||
if (isDefined(writer)) {
|
||||
@@ -320,6 +326,10 @@ export const useAgentChatSubscription = (threadId: string | null) => {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'keepalive': {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream-error': {
|
||||
const streamError = new Error(event.message) as Error & {
|
||||
code?: string;
|
||||
@@ -388,6 +398,8 @@ export const useAgentChatSubscription = (threadId: string | null) => {
|
||||
},
|
||||
{
|
||||
next: (value: ExecutionResult<AgentChatEventPayload>) => {
|
||||
store.set(agentChatStreamLastEventTimestampState.atom, Date.now());
|
||||
|
||||
if (isDefined(value.data?.onAgentChatEvent?.event)) {
|
||||
handleEvent(
|
||||
value.data.onAgentChatEvent.event as AgentChatSubscriptionEvent,
|
||||
@@ -417,6 +429,7 @@ export const useAgentChatSubscription = (threadId: string | null) => {
|
||||
}, [
|
||||
threadId,
|
||||
sseClient,
|
||||
agentChatStreamResubscribeNonce,
|
||||
store,
|
||||
errorFamilyCallback,
|
||||
isStreamingFamilyCallback,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const agentChatStreamLastEventTimestampState = createAtomState<
|
||||
number | null
|
||||
>({
|
||||
key: 'agentChatStreamLastEventTimestampState',
|
||||
defaultValue: null,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const agentChatStreamResubscribeNonceState = createAtomState<number>({
|
||||
key: 'agentChatStreamResubscribeNonceState',
|
||||
defaultValue: 0,
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
|
||||
import { tokenPairState } from '@/auth/states/tokenPairState';
|
||||
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
|
||||
import { SSE_CLIENT_RECONNECTED_EVENT_NAME } from '@/sse-db-event/constants/SseClientReconnectedEventName';
|
||||
import { useHandleSseClientConnectionRetry } from '@/sse-db-event/hooks/useHandleSseClientConnectionRetry';
|
||||
import { activeQueryListenersState } from '@/sse-db-event/states/activeQueryListenersState';
|
||||
import { sseClientState } from '@/sse-db-event/states/sseClientState';
|
||||
@@ -18,15 +20,22 @@ export const SSEClientEffect = () => {
|
||||
const [sseClient, setSseClient] = useAtomState(sseClientState);
|
||||
const tokenPair = useAtomStateValue(tokenPairState);
|
||||
|
||||
const handleSSEClientConnected = useCallback(() => {
|
||||
const currentActiveQueryListeners = store.get(
|
||||
activeQueryListenersState.atom,
|
||||
);
|
||||
const handleSSEClientConnected = useCallback(
|
||||
(reconnected: boolean) => {
|
||||
const currentActiveQueryListeners = store.get(
|
||||
activeQueryListenersState.atom,
|
||||
);
|
||||
|
||||
if (isNonEmptyArray(currentActiveQueryListeners)) {
|
||||
store.set(activeQueryListenersState.atom, []);
|
||||
}
|
||||
}, [store]);
|
||||
if (isNonEmptyArray(currentActiveQueryListeners)) {
|
||||
store.set(activeQueryListenersState.atom, []);
|
||||
}
|
||||
|
||||
if (reconnected) {
|
||||
dispatchBrowserEvent(SSE_CLIENT_RECONNECTED_EVENT_NAME);
|
||||
}
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const { handleSseClientConnectionRetry } =
|
||||
useHandleSseClientConnectionRetry();
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const SSE_CLIENT_RECONNECTED_EVENT_NAME =
|
||||
'sse-client-reconnected' as const;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const AI_STREAM_LOCK_DURATION_MS = 10 * 60 * 1000;
|
||||
@@ -108,6 +108,9 @@ export class BullMQDriver
|
||||
...(isDefined(options?.concurrency)
|
||||
? { concurrency: options.concurrency }
|
||||
: {}),
|
||||
...(isDefined(options?.lockDuration)
|
||||
? { lockDuration: options.lockDuration }
|
||||
: {}),
|
||||
metrics: {
|
||||
maxDataPoints: MetricsTime.ONE_WEEK,
|
||||
collectInterval: 60000,
|
||||
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
export interface MessageQueueWorkerOptions {
|
||||
concurrency?: number;
|
||||
lockDuration?: number;
|
||||
}
|
||||
|
||||
+7
-2
@@ -1,8 +1,13 @@
|
||||
import { AI_STREAM_LOCK_DURATION_MS } from 'src/engine/core-modules/message-queue/constants/ai-stream-lock-duration.constant';
|
||||
import { type MessageQueueWorkerOptions } from 'src/engine/core-modules/message-queue/interfaces/message-queue-worker-options.interface';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
|
||||
export const QUEUE_WORKER_OPTIONS: Partial<
|
||||
Record<MessageQueue, { concurrency: number }>
|
||||
Record<MessageQueue, MessageQueueWorkerOptions>
|
||||
> = {
|
||||
[MessageQueue.aiStreamQueue]: { concurrency: 20 },
|
||||
[MessageQueue.aiStreamQueue]: {
|
||||
concurrency: 20,
|
||||
lockDuration: AI_STREAM_LOCK_DURATION_MS,
|
||||
},
|
||||
[MessageQueue.logicFunctionQueue]: { concurrency: 10 },
|
||||
};
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const AGENT_CHAT_KEEPALIVE_INTERVAL_MS = 2_000;
|
||||
+10
@@ -509,6 +509,16 @@ export class StreamAgentChatJob {
|
||||
|
||||
const userMessage = await userMessagePromise;
|
||||
|
||||
if (
|
||||
isDefined(userMessage.turnId) &&
|
||||
(await this.agentChatService.hasAssistantMessageForTurn({
|
||||
turnId: userMessage.turnId,
|
||||
workspaceId,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.agentChatService.addMessage({
|
||||
threadId,
|
||||
uiMessage: responseMessage,
|
||||
|
||||
+24
-1
@@ -17,9 +17,11 @@ import {
|
||||
AiExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai.exception';
|
||||
import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor';
|
||||
import { AGENT_CHAT_KEEPALIVE_INTERVAL_MS } from 'src/engine/metadata-modules/ai/ai-chat/constants/agent-chat-keepalive-interval-ms.constant';
|
||||
import { AgentChatEventDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-event.dto';
|
||||
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
|
||||
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
||||
import { wrapAsyncIteratorWithLifecycle } from 'src/engine/subscriptions/utils/wrap-async-iterator-with-lifecycle';
|
||||
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
@MetadataResolver()
|
||||
@@ -58,9 +60,30 @@ export class AgentChatSubscriptionResolver {
|
||||
);
|
||||
}
|
||||
|
||||
return this.subscriptionService.subscribeToAgentChat({
|
||||
const iterator = await this.subscriptionService.subscribeToAgentChat({
|
||||
workspaceId: workspace.id,
|
||||
threadId,
|
||||
});
|
||||
|
||||
const keepalivePayload = {
|
||||
onAgentChatEvent: {
|
||||
threadId,
|
||||
event: { type: 'keepalive' as const },
|
||||
},
|
||||
};
|
||||
|
||||
return wrapAsyncIteratorWithLifecycle(iterator, {
|
||||
initialValue: keepalivePayload,
|
||||
onHeartbeat: async () => {
|
||||
await this.subscriptionService.publishToAgentChat({
|
||||
workspaceId: workspace.id,
|
||||
threadId,
|
||||
payload: keepalivePayload,
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
heartbeatIntervalMs: AGENT_CHAT_KEEPALIVE_INTERVAL_MS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { ExtendedUIMessage } from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { In, IsNull, Not } from 'typeorm';
|
||||
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
@@ -259,6 +260,21 @@ export class AgentChatService {
|
||||
} as AgentMessageEntity;
|
||||
}
|
||||
|
||||
async hasAssistantMessageForTurn({
|
||||
turnId,
|
||||
workspaceId,
|
||||
}: {
|
||||
turnId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<boolean> {
|
||||
const existingMessage = await this.messageRepository.findOne(workspaceId, {
|
||||
where: { turnId, role: AgentMessageRole.ASSISTANT },
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
return isDefined(existingMessage);
|
||||
}
|
||||
|
||||
async getMessagesForThread({
|
||||
threadId,
|
||||
userWorkspaceId,
|
||||
|
||||
@@ -6,4 +6,5 @@ export type AgentChatSubscriptionEvent =
|
||||
| { type: 'message-persisted'; messageId: string }
|
||||
| { type: 'queue-updated' }
|
||||
| { type: 'stream-error'; code: string; message: string }
|
||||
| { type: 'credits-exhausted' };
|
||||
| { type: 'credits-exhausted' }
|
||||
| { type: 'keepalive' };
|
||||
|
||||
Reference in New Issue
Block a user