Skip to content

Commit 0668369

Browse files
committed
fix(mothership): answer a send superseded at admission as a duplicate
Admission's superseded conflict (another attempt with this id re-took the claim) went out as a bare 409, which the client reads as a busy refusal and marks the message editable. The claim's 60s in-progress TTL can run out before admission, since branch, attachment and context preparation precede it, so that attempt may admit the turn. The route now answers it as a duplicate naming the send's id, and the client keeps the message under it.
1 parent ed03246 commit 0668369

5 files changed

Lines changed: 54 additions & 6 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4053,10 +4053,9 @@ export function useChat(
40534053
exact: true,
40544054
refetchType: 'none',
40554055
})
4056-
/* Admission's "superseded" conflict (another attempt re-took this id's
4057-
claim) also lands here with no stream named. That needs this attempt to
4058-
hold its claim past the 60s in-progress TTL before admitting, which a
4059-
lock wait of at most 5s does not reach. */
4056+
/* Only the chat lock refuses without naming this id. Admission's
4057+
"superseded" conflict, where another attempt took this id's claim and may
4058+
admit it, is answered as a duplicate naming this id instead. */
40604059
return { userMessageId, busy: true, notAdmitted: true }
40614060
}
40624061
/* "Already sent" with no stream for it means the earlier attempt is still

‎apps/sim/lib/mothership/chat/application/admit-turn.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { requireOrganizationSearchAvailable } from '@/lib/knowledge/access/avail
1212
import { insertRunSegment, withRunAdmissionLock } from '@/lib/mothership/async-runs/repository'
1313
import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case'
1414
import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context'
15+
import { ChatSendSupersededError } from '@/lib/mothership/chat/application/send-superseded'
1516
import { withChatEffortChoice } from '@/lib/mothership/chat/intent'
1617
import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store'
1718
import { authorizeOrganizationChat } from '@/lib/mothership/chat/organization-chats'
@@ -167,8 +168,7 @@ export const admitChatTurn = defineAuthorizedChatUseCase({
167168
)
168169
)
169170
.returning({ key: idempotencyKey.key })
170-
if (!claim)
171-
throw new OrchestrationError('conflict', 'This send was superseded; retry the message')
171+
if (!claim) throw new ChatSendSupersededError()
172172
return run
173173
})
174174
},
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { OrchestrationError } from '@/lib/core/orchestration/types'
2+
3+
/**
4+
* Admission found this send's claim taken by another attempt with the same
5+
* `userMessageId`: this attempt held its in-progress claim past the 60s TTL
6+
* before admitting (branch, attachment and context preparation can run that
7+
* long), and a retry re-claimed it. That attempt may admit the turn, so the
8+
* client must treat this like a duplicate, not a refusal.
9+
*/
10+
export class ChatSendSupersededError extends OrchestrationError {
11+
constructor() {
12+
super('conflict', 'This send was superseded; retry the message')
13+
this.name = 'ChatSendSupersededError'
14+
}
15+
}

‎apps/sim/lib/mothership/chat/post.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScop
216216
vi.mock('@/lib/mothership/chat-status', () => mothershipChatStatusMock)
217217

218218
import { chatOperations } from '@/lib/mothership/application/operations'
219+
import { ChatSendSupersededError } from '@/lib/mothership/chat/application/send-superseded'
219220
import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields'
220221
import { handleUnifiedChatPost } from './post'
221222

@@ -1689,6 +1690,29 @@ describe('handleUnifiedChatPost', () => {
16891690
expect(response.headers.get('x-mothership-chat-id')).toBeNull()
16901691
})
16911692

1693+
/**
1694+
* Another attempt with this id took the claim while this one was still
1695+
* preparing, and may admit the turn. The client must keep the message under
1696+
* this id, so the answer names it, as a duplicate's does.
1697+
*/
1698+
it('answers a send superseded at admission as a duplicate naming its id', async () => {
1699+
admitTurn.mockRejectedValueOnce(new ChatSendSupersededError())
1700+
const response = await handleUnifiedChatPost(
1701+
new NextRequest('http://localhost/api/mothership/chat', {
1702+
method: 'POST',
1703+
body: JSON.stringify({
1704+
message: 'Hello',
1705+
workspaceId: 'ws-1',
1706+
userMessageId: 'msg-1',
1707+
createNewChat: true,
1708+
}),
1709+
})
1710+
)
1711+
expect(response.status).toBe(409)
1712+
await expect(response.json()).resolves.toMatchObject({ activeStreamId: 'msg-1' })
1713+
expect(createSSEStream).not.toHaveBeenCalled()
1714+
})
1715+
16921716
it('keeps the claim once a turn is actually streaming', async () => {
16931717
const response = await handleUnifiedChatPost(
16941718
new NextRequest('http://localhost/api/mothership/chat', {

‎apps/sim/lib/mothership/chat/post.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { loadCopilotSearchIntegrations } from '@/lib/mothership/application/load
3232
import { chatOperations } from '@/lib/mothership/application/operations'
3333
import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target'
3434
import { admitChatTurn } from '@/lib/mothership/chat/application/admit-turn'
35+
import { ChatSendSupersededError } from '@/lib/mothership/chat/application/send-superseded'
3536
import {
3637
type AssistantImageContent,
3738
prepareOrganizationChatAttachments,
@@ -1627,6 +1628,15 @@ export async function handleUnifiedChatPost(req: NextRequest) {
16271628
}
16281629

16291630
const applicationError = asOrchestrationError(error)
1631+
/* Another attempt with this id holds its claim and may admit the turn. Answer
1632+
as a duplicate (naming this id), so the client keeps the message under it
1633+
rather than reading a refusal it could edit into a second turn. */
1634+
if (applicationError instanceof ChatSendSupersededError) {
1635+
return NextResponse.json(
1636+
{ error: 'This message was already sent.', activeStreamId: userMessageId },
1637+
{ status: 409 }
1638+
)
1639+
}
16301640
if (applicationError?.code === 'forbidden' || applicationError?.code === 'not_found') {
16311641
return NextResponse.json({ error: 'Conversation access denied' }, { status: 403 })
16321642
}

0 commit comments

Comments
 (0)