Skip to content

Commit 00b6cee

Browse files
authored
fix(mothership): a pure resend verdict, and Send-now drops a message the server already has (#8744)
* refactor(mothership): a pure resend verdict, one discard helper, and Send-now honours it - resendVerdict(entry, history | null) returns send, wait or drop, and the queue drain acts on it, instead of a boolean check with side effects - discardQueuedSend replaces the three copies of clear handoff, clear claim, remove - Send-now checks the history too and drops a message the server already accepted instead of resending it (G5); a history it cannot read does not hold back a send the user asked for - the own-id conflict test reaches that branch through a restored Send-now, since a message the history shows accepted is now dropped first * test(mothership): lock the queue upgrade path and tighten held waits - rehydrate rows for the production shape (retryRequired: false, no hold) and for a queue already in the new shape - the offline-hold waits check hold === 'online' instead of any hold - the one-lookup comment names where the one-hop invariant is enforced * fix(mothership): don't stop the running turn for a Send-now gone during its history read Send-now reads the chat's history before stopping the running turn. A message removed, edited or dispatched in that time, or a view that moved on, still stopped the turn. It now re-reads the queue and checks the chat and mount are current before going on. * test(mothership): cover Send-now's re-checks after its history read Send-now no longer stops a turn in a chat the user moved to during the read, or from a surface that unmounted during it, and leaves a message the drain dispatched meanwhile to that dispatch. applyHeldResend is renamed applyResendVerdict.
1 parent 3899fde commit 00b6cee

6 files changed

Lines changed: 417 additions & 59 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/hooks/send-queue-policy.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { describe, expect, it } from 'vitest'
22
import {
33
requeuedFields,
4+
resendVerdict,
45
sendPayload,
56
withoutRequeueFields,
67
} from '@/app/workspace/[workspaceId]/home/hooks/send-queue-policy'
8+
import type { MothershipChatHistory } from '@/hooks/queries/mothership-chats'
79

810
describe('requeuedFields', () => {
911
it('holds an offline send for the network, on its chatless surface', () => {
@@ -72,3 +74,44 @@ describe('sendPayload', () => {
7274
).toEqual({ content: 'hello', requestMode: 'assistant' })
7375
})
7476
})
77+
78+
describe('resendVerdict', () => {
79+
const history = (accepted: string[] = [], activeStreamId: string | null = null) =>
80+
({
81+
id: 'chat-A',
82+
mode: 'agent',
83+
title: 'A',
84+
messages: accepted.map((id) => ({
85+
id,
86+
role: 'user' as const,
87+
content: 'sent',
88+
timestamp: new Date(0).toISOString(),
89+
})),
90+
activeStreamId,
91+
resources: [],
92+
}) satisfies MothershipChatHistory
93+
const resumed = {
94+
id: 'm1',
95+
content: 'hello',
96+
resumeUserMessageId: 'attempt-1',
97+
admissionUnknown: true,
98+
}
99+
100+
it('sends a message the server cannot already hold, without reading history', () => {
101+
expect(resendVerdict({ id: 'm1', content: 'hello' }, null)).toBe('send')
102+
expect(resendVerdict({ ...resumed, admissionUnknown: false }, null)).toBe('send')
103+
})
104+
105+
it('drops a message the history shows accepted, as a message or as the running turn', () => {
106+
expect(resendVerdict(resumed, history(['attempt-1']))).toBe('drop')
107+
expect(resendVerdict(resumed, history([], 'attempt-1'))).toBe('drop')
108+
})
109+
110+
it('sends a message the history does not show', () => {
111+
expect(resendVerdict(resumed, history(['other']))).toBe('send')
112+
})
113+
114+
it('waits when the history could not be read', () => {
115+
expect(resendVerdict(resumed, null)).toBe('wait')
116+
})
117+
})

‎apps/sim/app/workspace/[workspaceId]/home/hooks/send-queue-policy.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { backoffWithJitter } from '@sim/utils/retry'
22
import type { SendPayload } from '@/app/workspace/[workspaceId]/home/types'
3+
import type { MothershipChatHistory } from '@/hooks/queries/mothership-chats'
4+
import { reusedRequestId } from '@/stores/mothership-queue/store'
35
import type { QueuedMothershipMessage, SendRetry } from '@/stores/mothership-queue/types'
46

57
/**
@@ -87,3 +89,40 @@ export function sendPayload(source: SendPayload): SendPayload {
8789
: {}),
8890
}
8991
}
92+
93+
/** Ids of the sends a chat's history shows the server accepted: its user messages and running turn. */
94+
export function acceptedMessageIds(history: MothershipChatHistory): Set<string> {
95+
const ids = new Set(
96+
history.messages.filter((message) => message.role === 'user').map((message) => message.id)
97+
)
98+
if (history.activeStreamId) ids.add(history.activeStreamId)
99+
return ids
100+
}
101+
102+
/**
103+
* Whether a queued message must be checked against its chat's history before it
104+
* goes out: it may already be a turn on the server, under the id it reuses.
105+
*/
106+
export function needsResendCheck(entry: QueuedMothershipMessage): boolean {
107+
return entry.admissionUnknown === true && reusedRequestId(entry) !== undefined
108+
}
109+
110+
/** What to do with a queued message about to go out. */
111+
export type ResendVerdict = 'send' | 'wait' | 'drop'
112+
113+
/**
114+
* Whether a queued message may go out, given its chat's history read fresh
115+
* (`null` when the read failed). The server deduplicates a resend only while the
116+
* earlier attempt's claim lasts, which a long outage outlives, so a message the
117+
* history shows accepted is dropped (`drop`). One whose history cannot be read
118+
* waits (`wait`): the read failing says nothing about whether it ran.
119+
*/
120+
export function resendVerdict(
121+
entry: QueuedMothershipMessage,
122+
history: MothershipChatHistory | null
123+
): ResendVerdict {
124+
const requestId = reusedRequestId(entry)
125+
if (!needsResendCheck(entry) || requestId === undefined) return 'send'
126+
if (!history) return 'wait'
127+
return acceptedMessageIds(history).has(requestId) ? 'drop' : 'send'
128+
}

0 commit comments

Comments
 (0)