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:
Félix Malfait
2026-06-26 15:19:34 +02:00
committed by GitHub
parent 8f7d6c24dd
commit da6a2ee300
18 changed files with 190 additions and 12 deletions
@@ -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;
};
@@ -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();
@@ -0,0 +1,2 @@
export const SSE_CLIENT_RECONNECTED_EVENT_NAME =
'sse-client-reconnected' as const;
@@ -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,3 +1,4 @@
export interface MessageQueueWorkerOptions {
concurrency?: number;
lockDuration?: number;
}
@@ -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 },
};
@@ -0,0 +1 @@
export const AGENT_CHAT_KEEPALIVE_INTERVAL_MS = 2_000;
@@ -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,
@@ -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,
});
}
}
@@ -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' };