Skip to content

Commit f0d5157

Browse files
committed
fix(mothership): honest replay dedupe on execute + headless flush skip
The execute NDJSON forwarder guessed delta-vs-cumulative by string prefix and could slice real characters off a delta that happened to begin with the forwarded content. The wire's ordering key decides now: seq rides the StreamEvent projection (it was dropped at construction), and the forwarder skips only seqs it has already sent. The per-event macrotask yield exists to flush the HTTP response buffer; headless legs (no caller sink) now opt out via flushAfterEvent, declared where the sink is owned. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w
1 parent effd349 commit f0d5157

7 files changed

Lines changed: 32 additions & 10 deletions

File tree

apps/sim/app/api/mothership/execute/route.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
350350

351351
const stream = new ReadableStream<Uint8Array>({
352352
start(controller) {
353-
let forwardedAssistantContent = ''
353+
let lastForwardedTextSeq = -1
354354
const send = (event: unknown) => {
355355
if (!cancelled) {
356356
controller.enqueue(encodeNdjson(event))
@@ -372,14 +372,15 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
372372
event.payload.channel === MothershipStreamV1TextChannel.assistant &&
373373
event.payload.text
374374
) {
375-
const text = event.payload.text
376-
const content = text.startsWith(forwardedAssistantContent)
377-
? text.slice(forwardedAssistantContent.length)
378-
: text
379-
if (content) {
380-
forwardedAssistantContent += content
381-
send({ type: 'chunk', content })
375+
/* The wire carries text DELTAS with monotone seqs; a transport-retry
376+
replay re-delivers earlier seqs. Dedupe replays by seq — the old
377+
string-prefix guess sliced characters off a genuine delta that
378+
happened to begin with the already-forwarded content. */
379+
if (typeof event.seq === 'number') {
380+
if (event.seq <= lastForwardedTextSeq) return
381+
lastForwardedTextSeq = event.seq
382382
}
383+
send({ type: 'chunk', content: event.payload.text })
383384
}
384385
})
385386
allowExplicitAbort = false

apps/sim/lib/mothership/request/go/stream.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,10 @@ export async function runStreamLoop(
405405
// Yield a macrotask so Node.js flushes the HTTP response buffer to
406406
// the browser. Microtask yields (await Promise.resolve()) are not
407407
// enough — the I/O layer needs a full event loop tick to write.
408-
await new Promise<void>((resolve) => setImmediate(resolve))
408+
// Headless legs (no client response attached) opt out via flushAfterEvent.
409+
if (options.flushAfterEvent !== false) {
410+
await new Promise<void>((resolve) => setImmediate(resolve))
411+
}
409412

410413
if (options.onBeforeDispatch?.(streamEvent, context)) {
411414
return context.streamComplete || undefined

apps/sim/lib/mothership/request/lifecycle/run.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,10 @@ async function runCheckpointLoop(
912912

913913
const loopOptions = {
914914
...options,
915+
/* The wrapper below always exists (checkpoint bookkeeping), so the forwarder can't
916+
infer "headless" from onEvent's absence — declare it: only a caller-attached sink
917+
has an HTTP buffer worth a per-event macrotask flush. */
918+
flushAfterEvent: options.flushAfterEvent ?? Boolean(callerOnEvent),
915919
onEvent: async (event: StreamEvent) => {
916920
if (
917921
event.type === MothershipStreamV1EventType.run &&

apps/sim/lib/mothership/request/session/contract.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ type EnvelopeToStreamEvent<T> = T extends {
3333
payload: infer TPayload
3434
scope?: infer TScope
3535
}
36-
? { type: TType; payload: TPayload; scope?: Exclude<TScope, undefined> }
36+
? {
37+
type: TType
38+
payload: TPayload
39+
scope?: Exclude<TScope, undefined>
40+
/** Wire ordering key, carried off the envelope; absent on synthetic events. */
41+
seq?: number
42+
}
3743
: never
3844

3945
export type SyntheticFilePreviewPhase = (typeof FILE_PREVIEW_PHASE)[keyof typeof FILE_PREVIEW_PHASE]

apps/sim/lib/mothership/request/session/event.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ describe('createEvent', () => {
5353
const streamEvent = eventToStreamEvent(envelope)
5454
expect(streamEvent).toEqual({
5555
type: MothershipStreamV1EventType.tool,
56+
seq: 2,
5657
payload: {
5758
previewPhase: 'file_preview_start',
5859
toolCallId: 'preview-1',

apps/sim/lib/mothership/request/session/event.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export function eventToStreamEvent<TEnvelope extends PersistedStreamEventEnvelop
6969
return {
7070
type: envelope.type,
7171
payload: envelope.payload,
72+
seq: envelope.seq,
7273
...(envelope.scope ? { scope: envelope.scope } : {}),
7374
} as StreamEventFromEnvelope<TEnvelope>
7475
}

apps/sim/lib/mothership/request/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,12 @@ export interface OrchestratorOptions {
220220
autoExecuteTools?: boolean
221221
timeout?: number
222222
onEvent?: (event: StreamEvent) => void | Promise<void>
223+
/**
224+
* Whether the per-event macrotask yield (which lets Node flush the HTTP response buffer)
225+
* should run. The sink owner sets this: legs with no client response attached have
226+
* nothing to flush, and the yield only slows the forwarder. Defaults to true.
227+
*/
228+
flushAfterEvent?: boolean
223229
onComplete?: (result: OrchestratorResult) => void | Promise<void>
224230
onError?: (error: Error, result?: OrchestratorResult) => void | Promise<void>
225231
abortSignal?: AbortSignal

0 commit comments

Comments
 (0)