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:
Félix Malfait
2026-07-02 21:33:20 +02:00
committed by GitHub
parent 087bee0036
commit 027a098099
2 changed files with 53 additions and 17 deletions
@@ -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;
}
@@ -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(