fix(ai): fully roll back a question answer when the resume enqueue fails (#22492)
## Rationale `answerAgentChatQuestion` is a three-step transaction without the transaction: resolve the question (flip tool part to `answered`, clear `pendingQuestionMessageId`, claim the stream), then enqueue the resume job. If the **enqueue fails**, the catch restores only `activeStreamId`. What's left behind: tool part says `answered`, `pendingQuestionMessageId` is `null`, no job will ever run. The client's own error handler rolls its card back to *pending* — so the user sees an answerable question whose re-submission deterministically throws `QUESTION_NOT_PENDING`. The turn is stuck and state is divergent on three surfaces (DB part, DB thread, client). ## Why this is the root cause, not a symptom patch The failure path was rolling back one of three writes. This makes the rollback total and **exact**: `resolvePendingQuestion` now returns the part's precise previous `toolOutput` (no reconstruction guesswork — question tools can carry arbitrary output fields), and the failure path restores the part verbatim plus the thread's pending-question state, guarded on the observed streamId so a competing claim is never clobbered. After rollback, server and client agree again: the question is pending, answering retries cleanly. The audit's alternative — forward recovery (keep the answers, mark the turn interrupted, resume via Retry) — has nicer UX in isolation but contradicts the client's existing rollback-to-pending behavior; matching the established contract wins until the client changes. ## User impact A transient Redis/queue hiccup at answer time currently bricks the question turn permanently. With this, the user sees the question again and can just re-answer. ## Test plan - [ ] CI green - [ ] Manual: fail the enqueue (kill Redis briefly) at answer time → question card returns to pending, re-answer succeeds 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/22492?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:
+15
-15
@@ -304,13 +304,14 @@ export class AgentChatResolver {
|
||||
|
||||
const streamId = generateId();
|
||||
|
||||
const { turnId } = await this.agentChatService.resolvePendingQuestion({
|
||||
threadId,
|
||||
messageId,
|
||||
answers,
|
||||
streamId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
const { turnId, rollback } =
|
||||
await this.agentChatService.resolvePendingQuestion({
|
||||
threadId,
|
||||
messageId,
|
||||
answers,
|
||||
streamId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
await this.eventPublisherService
|
||||
.publish({
|
||||
@@ -330,14 +331,13 @@ export class AgentChatResolver {
|
||||
modelId,
|
||||
});
|
||||
} catch (error) {
|
||||
// Roll back the streaming claim so the thread isn't stuck "streaming".
|
||||
await this.threadRepository
|
||||
.update(
|
||||
workspace.id,
|
||||
{ id: threadId, activeStreamId: streamId },
|
||||
{ activeStreamId: null },
|
||||
)
|
||||
.catch(() => {});
|
||||
await this.agentChatService.restorePendingQuestion({
|
||||
threadId,
|
||||
messageId,
|
||||
streamId,
|
||||
workspaceId: workspace.id,
|
||||
rollback,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
+38
-2
@@ -499,7 +499,10 @@ export class AgentChatService {
|
||||
answers: AskQuestionAnswer[];
|
||||
streamId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<{ turnId: string | null }> {
|
||||
}): Promise<{
|
||||
turnId: string | null;
|
||||
rollback: { partId: string; previousOutput: Record<string, unknown> };
|
||||
}> {
|
||||
const message = await this.messageRepository.findOne(workspaceId, {
|
||||
where: { id: messageId, threadId },
|
||||
relations: ['parts'],
|
||||
@@ -576,7 +579,40 @@ export class AgentChatService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { turnId: message.turnId };
|
||||
return {
|
||||
turnId: message.turnId,
|
||||
rollback: { partId: pendingPart.id, previousOutput },
|
||||
};
|
||||
}
|
||||
|
||||
async restorePendingQuestion({
|
||||
threadId,
|
||||
messageId,
|
||||
streamId,
|
||||
workspaceId,
|
||||
rollback,
|
||||
}: {
|
||||
threadId: string;
|
||||
messageId: string;
|
||||
streamId: string;
|
||||
workspaceId: string;
|
||||
rollback: { partId: string; previousOutput: Record<string, unknown> };
|
||||
}): Promise<void> {
|
||||
await this.messagePartRepository
|
||||
.update(
|
||||
workspaceId,
|
||||
{ id: rollback.partId },
|
||||
{ toolOutput: rollback.previousOutput },
|
||||
)
|
||||
.catch(() => {});
|
||||
|
||||
await this.threadRepository
|
||||
.update(
|
||||
workspaceId,
|
||||
{ id: threadId, activeStreamId: streamId },
|
||||
{ pendingQuestionMessageId: messageId, activeStreamId: null },
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
private validateQuestionAnswers(
|
||||
|
||||
Reference in New Issue
Block a user