da6a2ee300
## 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. -->
444 lines
15 KiB
TypeScript
444 lines
15 KiB
TypeScript
import { useEffect } from 'react';
|
|
|
|
import { readUIMessageStream, type UIMessageChunk } from 'ai';
|
|
import { print, type ExecutionResult } from 'graphql';
|
|
import { useStore } from 'jotai';
|
|
import {
|
|
type AgentChatSubscriptionEvent,
|
|
type ExtendedUIMessage,
|
|
} from 'twenty-shared/ai';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import { v4 } from 'uuid';
|
|
|
|
import { AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME } from '@/ai/constants/AgentChatRefetchMessagesEventName';
|
|
import { ON_AGENT_CHAT_EVENT } from '@/ai/graphql/subscriptions/OnAgentChatEvent';
|
|
import { agentChatErrorComponentFamilyState } from '@/ai/states/agentChatErrorComponentFamilyState';
|
|
import { agentChatFirstLiveSeqComponentFamilyState } from '@/ai/states/agentChatFirstLiveSeqComponentFamilyState';
|
|
import { agentChatHandleEventCallbackComponentFamilyState } from '@/ai/states/agentChatHandleEventCallbackComponentFamilyState';
|
|
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';
|
|
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
|
import { dispatchBrowserEvent } from '@/browser-event/utils/dispatchBrowserEvent';
|
|
import { sseClientState } from '@/sse-db-event/states/sseClientState';
|
|
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
|
|
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
|
import { BillingProductKey } from '~/generated-metadata/graphql';
|
|
|
|
const THROTTLE_MS = 100;
|
|
|
|
// readUIMessageStream requires initialization chunks (start, start-step,
|
|
// text-start) before content chunks. When reconnecting to a thread mid-stream,
|
|
// those chunks were already sent before we subscribed. This adapter injects
|
|
// synthetic initialization chunks so the reader can process mid-stream content.
|
|
const createMidStreamAdapter = () => {
|
|
let hasSeenStart = false;
|
|
const knownTextPartIds = new Set<string>();
|
|
const knownReasoningPartIds = new Set<string>();
|
|
const knownToolCallIds = new Set<string>();
|
|
|
|
return new TransformStream<UIMessageChunk, UIMessageChunk>({
|
|
transform(chunk, controller) {
|
|
if (!hasSeenStart) {
|
|
hasSeenStart = true;
|
|
if (chunk.type !== 'start') {
|
|
controller.enqueue({ type: 'start', messageId: v4() });
|
|
controller.enqueue({ type: 'start-step' });
|
|
}
|
|
}
|
|
|
|
if (chunk.type === 'text-start') {
|
|
knownTextPartIds.add(chunk.id);
|
|
} else if (
|
|
(chunk.type === 'text-delta' || chunk.type === 'text-end') &&
|
|
!knownTextPartIds.has(chunk.id)
|
|
) {
|
|
controller.enqueue({ type: 'text-start', id: chunk.id });
|
|
knownTextPartIds.add(chunk.id);
|
|
}
|
|
|
|
if (chunk.type === 'reasoning-start') {
|
|
knownReasoningPartIds.add(chunk.id);
|
|
} else if (
|
|
(chunk.type === 'reasoning-delta' || chunk.type === 'reasoning-end') &&
|
|
!knownReasoningPartIds.has(chunk.id)
|
|
) {
|
|
controller.enqueue({ type: 'reasoning-start', id: chunk.id });
|
|
knownReasoningPartIds.add(chunk.id);
|
|
}
|
|
|
|
if (chunk.type === 'tool-input-start') {
|
|
knownToolCallIds.add(chunk.toolCallId);
|
|
} else if (
|
|
chunk.type === 'tool-input-delta' &&
|
|
!knownToolCallIds.has(chunk.toolCallId)
|
|
) {
|
|
controller.enqueue({
|
|
type: 'tool-input-start',
|
|
toolCallId: chunk.toolCallId,
|
|
toolName: 'unknown',
|
|
});
|
|
knownToolCallIds.add(chunk.toolCallId);
|
|
}
|
|
|
|
controller.enqueue(chunk);
|
|
},
|
|
});
|
|
};
|
|
|
|
type AgentChatEventPayload = {
|
|
onAgentChatEvent: {
|
|
threadId: string;
|
|
event: AgentChatSubscriptionEvent;
|
|
};
|
|
};
|
|
|
|
export const useAgentChatSubscription = (threadId: string | null) => {
|
|
const store = useStore();
|
|
const sseClient = useAtomStateValue(sseClientState);
|
|
const agentChatStreamResubscribeNonce = useAtomStateValue(
|
|
agentChatStreamResubscribeNonceState,
|
|
);
|
|
|
|
const errorFamilyCallback = useAtomComponentFamilyStateCallbackState(
|
|
agentChatErrorComponentFamilyState,
|
|
);
|
|
const isStreamingFamilyCallback = useAtomComponentFamilyStateCallbackState(
|
|
agentChatIsStreamingComponentFamilyState,
|
|
);
|
|
const firstLiveSeqFamilyCallback = useAtomComponentFamilyStateCallbackState(
|
|
agentChatFirstLiveSeqComponentFamilyState,
|
|
);
|
|
const isAwaitingPersistedRefetchFamilyCallback =
|
|
useAtomComponentFamilyStateCallbackState(
|
|
agentChatIsAwaitingPersistedRefetchComponentFamilyState,
|
|
);
|
|
const handleEventCallbackFamilyCallback =
|
|
useAtomComponentFamilyStateCallbackState(
|
|
agentChatHandleEventCallbackComponentFamilyState,
|
|
);
|
|
const messagesFamilyCallback = useAtomComponentFamilyStateCallbackState(
|
|
agentChatMessagesComponentFamilyState,
|
|
);
|
|
const usageFamilyCallback = useAtomComponentFamilyStateCallbackState(
|
|
agentChatUsageComponentFamilyState,
|
|
);
|
|
const threadTitleFamilyCallback = useAtomComponentFamilyStateCallbackState(
|
|
currentAiChatThreadTitleComponentFamilyState,
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!isDefined(threadId) || !isDefined(sseClient)) {
|
|
return;
|
|
}
|
|
|
|
const familyKey = { threadId };
|
|
|
|
const errorAtom = errorFamilyCallback(familyKey);
|
|
const isStreamingAtom = isStreamingFamilyCallback(familyKey);
|
|
const firstLiveSeqAtom = firstLiveSeqFamilyCallback(familyKey);
|
|
const isAwaitingPersistedRefetchAtom =
|
|
isAwaitingPersistedRefetchFamilyCallback(familyKey);
|
|
const handleEventCallbackAtom =
|
|
handleEventCallbackFamilyCallback(familyKey);
|
|
const messagesAtom = messagesFamilyCallback(familyKey);
|
|
const usageAtom = usageFamilyCallback(familyKey);
|
|
const threadTitleAtom = threadTitleFamilyCallback(familyKey);
|
|
|
|
let bridge: TransformStream<UIMessageChunk> | null = null;
|
|
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let latestMessage: ExtendedUIMessage | null = null;
|
|
let writer: WritableStreamDefaultWriter<UIMessageChunk> | null = null;
|
|
let disposed = false;
|
|
|
|
store.set(firstLiveSeqAtom, null);
|
|
store.set(agentChatStreamLastEventTimestampState.atom, Date.now());
|
|
|
|
const closeWriter = () => {
|
|
if (isDefined(writer)) {
|
|
writer.close().catch(() => {});
|
|
writer = null;
|
|
}
|
|
};
|
|
|
|
const cleanupStream = () => {
|
|
closeWriter();
|
|
|
|
if (store.get(isStreamingAtom)) {
|
|
store.set(isStreamingAtom, false);
|
|
}
|
|
};
|
|
|
|
const flushToAtom = () => {
|
|
const messageToFlush = latestMessage;
|
|
|
|
if (!isDefined(messageToFlush)) {
|
|
return;
|
|
}
|
|
|
|
const currentMessages = store.get(messagesAtom);
|
|
|
|
const streamingMsgIndex = currentMessages.findIndex(
|
|
(message) => message.id === messageToFlush.id,
|
|
);
|
|
|
|
if (streamingMsgIndex >= 0) {
|
|
const updatedMessages = [...currentMessages];
|
|
|
|
updatedMessages[streamingMsgIndex] = messageToFlush;
|
|
store.set(messagesAtom, updatedMessages);
|
|
} else {
|
|
store.set(messagesAtom, [...currentMessages, messageToFlush]);
|
|
}
|
|
};
|
|
|
|
const scheduleAtomUpdate = (message: ExtendedUIMessage) => {
|
|
latestMessage = message;
|
|
|
|
if (!isDefined(throttleTimer)) {
|
|
flushToAtom();
|
|
|
|
throttleTimer = setTimeout(() => {
|
|
throttleTimer = null;
|
|
flushToAtom();
|
|
}, THROTTLE_MS);
|
|
}
|
|
};
|
|
|
|
const startReadLoop = async (readable: ReadableStream<UIMessageChunk>) => {
|
|
const messageStream = readUIMessageStream({ stream: readable });
|
|
|
|
let lastUsageCountedMessageId: string | null = null;
|
|
|
|
for await (const message of messageStream) {
|
|
const extendedMessage = message as ExtendedUIMessage;
|
|
|
|
const titlePart = extendedMessage.parts.find(
|
|
(part) => part.type === 'data-thread-title',
|
|
);
|
|
|
|
if (isDefined(titlePart) && titlePart.type === 'data-thread-title') {
|
|
store.set(threadTitleAtom, titlePart.data.title);
|
|
}
|
|
|
|
const metadata = extendedMessage.metadata as
|
|
| {
|
|
usage?: {
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
cachedInputTokens: number;
|
|
inputCredits: number;
|
|
outputCredits: number;
|
|
conversationSize: number;
|
|
};
|
|
model?: {
|
|
contextWindowTokens: number;
|
|
};
|
|
}
|
|
| undefined;
|
|
|
|
if (
|
|
isDefined(metadata?.usage) &&
|
|
isDefined(metadata?.model) &&
|
|
lastUsageCountedMessageId !== extendedMessage.id
|
|
) {
|
|
lastUsageCountedMessageId = extendedMessage.id;
|
|
|
|
const usage = metadata.usage;
|
|
const model = metadata.model;
|
|
|
|
store.set(usageAtom, (prev) => ({
|
|
lastMessage: {
|
|
inputTokens: usage.inputTokens,
|
|
outputTokens: usage.outputTokens,
|
|
cachedInputTokens: usage.cachedInputTokens,
|
|
inputCredits: usage.inputCredits,
|
|
outputCredits: usage.outputCredits,
|
|
},
|
|
conversationSize: usage.conversationSize,
|
|
contextWindowTokens: model.contextWindowTokens,
|
|
inputTokens: (prev?.inputTokens ?? 0) + usage.inputTokens,
|
|
outputTokens: (prev?.outputTokens ?? 0) + usage.outputTokens,
|
|
inputCredits: (prev?.inputCredits ?? 0) + usage.inputCredits,
|
|
outputCredits: (prev?.outputCredits ?? 0) + usage.outputCredits,
|
|
}));
|
|
}
|
|
|
|
scheduleAtomUpdate(extendedMessage);
|
|
}
|
|
|
|
if (isDefined(throttleTimer)) {
|
|
clearTimeout(throttleTimer);
|
|
throttleTimer = null;
|
|
}
|
|
flushToAtom();
|
|
|
|
if (!disposed) {
|
|
store.set(isStreamingAtom, false);
|
|
}
|
|
};
|
|
|
|
const handleEvent = (event: AgentChatSubscriptionEvent) => {
|
|
switch (event.type) {
|
|
case 'stream-chunk': {
|
|
if (isDefined(event.seq) && store.get(firstLiveSeqAtom) === null) {
|
|
store.set(firstLiveSeqAtom, event.seq);
|
|
}
|
|
|
|
if (!store.get(isStreamingAtom)) {
|
|
store.set(isStreamingAtom, true);
|
|
store.set(errorAtom, null);
|
|
|
|
bridge = new TransformStream<UIMessageChunk>();
|
|
writer = bridge.writable.getWriter();
|
|
|
|
const adaptedReadable = bridge.readable.pipeThrough(
|
|
createMidStreamAdapter(),
|
|
);
|
|
|
|
startReadLoop(adaptedReadable).catch(() => {
|
|
if (!disposed) {
|
|
store.set(isStreamingAtom, false);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (isDefined(writer)) {
|
|
writer.write(event.chunk as UIMessageChunk).catch(() => {});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'message-persisted': {
|
|
closeWriter();
|
|
store.set(isAwaitingPersistedRefetchAtom, true);
|
|
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
|
|
break;
|
|
}
|
|
|
|
case 'queue-updated': {
|
|
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
|
|
break;
|
|
}
|
|
|
|
case 'keepalive': {
|
|
break;
|
|
}
|
|
|
|
case 'stream-error': {
|
|
const streamError = new Error(event.message) as Error & {
|
|
code?: string;
|
|
};
|
|
|
|
streamError.code = event.code;
|
|
store.set(errorAtom, streamError);
|
|
|
|
closeWriter();
|
|
store.set(isStreamingAtom, false);
|
|
break;
|
|
}
|
|
|
|
case 'credits-exhausted': {
|
|
//TODO : add real time on currentUser
|
|
store.set(currentWorkspaceState.atom, (currentWorkspace) => {
|
|
const currentBillingSubscription =
|
|
currentWorkspace?.currentBillingSubscription;
|
|
const billingSubscriptionItems =
|
|
currentBillingSubscription?.billingSubscriptionItems;
|
|
|
|
if (
|
|
!isDefined(currentWorkspace) ||
|
|
!isDefined(currentBillingSubscription) ||
|
|
!isDefined(billingSubscriptionItems)
|
|
) {
|
|
return currentWorkspace;
|
|
}
|
|
|
|
return {
|
|
...currentWorkspace,
|
|
currentBillingSubscription: {
|
|
...currentBillingSubscription,
|
|
billingSubscriptionItems: billingSubscriptionItems.map((item) =>
|
|
item.billingProduct.metadata?.['productKey'] ===
|
|
BillingProductKey.RESOURCE_CREDIT
|
|
? { ...item, hasReachedCurrentPeriodCap: true }
|
|
: item,
|
|
),
|
|
},
|
|
};
|
|
});
|
|
|
|
const noMoreCreditsError = new Error(
|
|
'Chat stopped: no more available credits.',
|
|
) as Error & { code?: string };
|
|
|
|
noMoreCreditsError.code = AiChatErrorCode.BILLING_CREDITS_EXHAUSTED;
|
|
store.set(errorAtom, noMoreCreditsError);
|
|
|
|
closeWriter();
|
|
store.set(isAwaitingPersistedRefetchAtom, true);
|
|
dispatchBrowserEvent(AGENT_CHAT_REFETCH_MESSAGES_EVENT_NAME);
|
|
store.set(isStreamingAtom, false);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
store.set(handleEventCallbackAtom, () => handleEvent);
|
|
|
|
const dispose = sseClient.subscribe<AgentChatEventPayload>(
|
|
{
|
|
query: print(ON_AGENT_CHAT_EVENT),
|
|
variables: { threadId },
|
|
},
|
|
{
|
|
next: (value: ExecutionResult<AgentChatEventPayload>) => {
|
|
store.set(agentChatStreamLastEventTimestampState.atom, Date.now());
|
|
|
|
if (isDefined(value.data?.onAgentChatEvent?.event)) {
|
|
handleEvent(
|
|
value.data.onAgentChatEvent.event as AgentChatSubscriptionEvent,
|
|
);
|
|
}
|
|
},
|
|
error: () => {
|
|
// graphql-sse handles reconnection automatically
|
|
},
|
|
complete: () => {
|
|
if (!disposed) {
|
|
cleanupStream();
|
|
}
|
|
},
|
|
},
|
|
);
|
|
|
|
return () => {
|
|
disposed = true;
|
|
store.set(handleEventCallbackAtom, null);
|
|
if (isDefined(throttleTimer)) {
|
|
clearTimeout(throttleTimer);
|
|
}
|
|
cleanupStream();
|
|
dispose();
|
|
};
|
|
}, [
|
|
threadId,
|
|
sseClient,
|
|
agentChatStreamResubscribeNonce,
|
|
store,
|
|
errorFamilyCallback,
|
|
isStreamingFamilyCallback,
|
|
firstLiveSeqFamilyCallback,
|
|
isAwaitingPersistedRefetchFamilyCallback,
|
|
handleEventCallbackFamilyCallback,
|
|
messagesFamilyCallback,
|
|
usageFamilyCallback,
|
|
threadTitleFamilyCallback,
|
|
]);
|
|
};
|