3b76ec528f
## Rationale When `StreamAgentChatJob` can't find the workspace, it publishes a transient `stream-error` event and **returns before the try/finally exists** (`stream-agent-chat.job.ts`). Consequences on main: - `activeStreamId` is never cleared → every subsequent send in that thread queues behind a dead claim, forever; - no `lastStreamError` is persisted → nothing renders after a reload, and Retry has nothing to retry; - nothing throws → **zero telemetry**. Sentry confirms: the "Workspace not found" issues that exist are all auth/Stripe paths — this path fails in complete silence. ## Why this is the root cause, not a symptom patch The job's catch/finally already implement the correct failure contract for *every other* error: persist a typed `lastStreamError`, publish the typed event, release the claim guarded on the observed streamId. The bug is that one code path bypasses that contract via an early return. The fix removes the bypass — the lookup moves inside the `try` and throws a typed `AiException(WORKSPACE_NOT_FOUND)` — rather than duplicating cleanup in the early-return branch (which would be the symptom patch, and would drift the next time the contract changes). The alternative "prevent the job from existing when the workspace is gone" isn't achievable: workspace deletion between enqueue and pickup is an inherent race, so the job must handle it regardless. ## User impact A workspace deleted/deactivated mid-flight currently bricks the thread silently (the user just sees sends vanish into a queue). With this, the failure is visible (typed error message), recoverable (standard failed-turn state), and observable (real exception in monitoring). ## Stack Based on #22479 (spec harness) — it extends the same spec file with the regression test. `WORKSPACE_NOT_FOUND` is a TypeScript enum member, not a GraphQL schema change: no client-sdk regeneration needed. ## Test plan - [x] Regression test: missing workspace → typed rejection, `lastStreamError` persisted, terminal event published, claim released - [ ] CI green https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38 --- _Generated by [Claude Code](https://claude.ai/code/session_01Lyi6zTema2FMVVh8MD6c38)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22480?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. -->
77 lines
3.0 KiB
TypeScript
77 lines
3.0 KiB
TypeScript
import { type MessageDescriptor } from '@lingui/core';
|
|
import { msg } from '@lingui/core/macro';
|
|
import { assertUnreachable } from 'twenty-shared/utils';
|
|
|
|
import { CustomException } from 'src/utils/custom-exception';
|
|
|
|
export enum AiExceptionCode {
|
|
AGENT_NOT_FOUND = 'AGENT_NOT_FOUND',
|
|
AGENT_ALREADY_EXISTS = 'AGENT_ALREADY_EXISTS',
|
|
AGENT_IS_STANDARD = 'AGENT_IS_STANDARD',
|
|
AGENT_EXECUTION_FAILED = 'AGENT_EXECUTION_FAILED',
|
|
INVALID_AGENT_INPUT = 'INVALID_AGENT_INPUT',
|
|
THREAD_NOT_FOUND = 'THREAD_NOT_FOUND',
|
|
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
|
INVALID_CHAT_THREAD_TITLE = 'INVALID_CHAT_THREAD_TITLE',
|
|
MESSAGE_NOT_FOUND = 'MESSAGE_NOT_FOUND',
|
|
QUESTION_NOT_PENDING = 'QUESTION_NOT_PENDING',
|
|
INVALID_QUESTION_ANSWER = 'INVALID_QUESTION_ANSWER',
|
|
API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED',
|
|
USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND',
|
|
ROLE_NOT_FOUND = 'ROLE_NOT_FOUND',
|
|
ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS = 'ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS',
|
|
NO_FAILED_TURN_TO_RETRY = 'NO_FAILED_TURN_TO_RETRY',
|
|
}
|
|
|
|
const getAiExceptionUserFriendlyMessage = (code: AiExceptionCode) => {
|
|
switch (code) {
|
|
case AiExceptionCode.AGENT_NOT_FOUND:
|
|
return msg`Agent not found.`;
|
|
case AiExceptionCode.AGENT_ALREADY_EXISTS:
|
|
return msg`An agent with this name already exists.`;
|
|
case AiExceptionCode.AGENT_IS_STANDARD:
|
|
return msg`Standard agents cannot be modified.`;
|
|
case AiExceptionCode.AGENT_EXECUTION_FAILED:
|
|
return msg`Agent execution failed.`;
|
|
case AiExceptionCode.INVALID_AGENT_INPUT:
|
|
return msg`Invalid agent input.`;
|
|
case AiExceptionCode.THREAD_NOT_FOUND:
|
|
return msg`Chat thread not found.`;
|
|
case AiExceptionCode.WORKSPACE_NOT_FOUND:
|
|
return msg`Workspace not found.`;
|
|
case AiExceptionCode.INVALID_CHAT_THREAD_TITLE:
|
|
return msg`Chat thread title cannot be empty.`;
|
|
case AiExceptionCode.MESSAGE_NOT_FOUND:
|
|
return msg`Chat message not found.`;
|
|
case AiExceptionCode.QUESTION_NOT_PENDING:
|
|
return msg`This question has already been answered.`;
|
|
case AiExceptionCode.INVALID_QUESTION_ANSWER:
|
|
return msg`Invalid answer for this question.`;
|
|
case AiExceptionCode.API_KEY_NOT_CONFIGURED:
|
|
return msg`API key is not configured.`;
|
|
case AiExceptionCode.USER_WORKSPACE_ID_NOT_FOUND:
|
|
return msg`User workspace not found.`;
|
|
case AiExceptionCode.ROLE_NOT_FOUND:
|
|
return msg`Role not found.`;
|
|
case AiExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS:
|
|
return msg`This role cannot be assigned to agents.`;
|
|
case AiExceptionCode.NO_FAILED_TURN_TO_RETRY:
|
|
return msg`There is no failed message to retry.`;
|
|
default:
|
|
assertUnreachable(code);
|
|
}
|
|
};
|
|
|
|
export class AiException extends CustomException<AiExceptionCode> {
|
|
constructor(
|
|
message: string,
|
|
code: AiExceptionCode,
|
|
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
|
) {
|
|
super(message, code, {
|
|
userFriendlyMessage:
|
|
userFriendlyMessage ?? getAiExceptionUserFriendlyMessage(code),
|
|
});
|
|
}
|
|
}
|