Skip to content

Commit ae6d45b

Browse files
authored
fix(mothership): parse each checkpoint control request on its own and declare accepted kinds (#9086)
The outbound receiver parsed a poll batch strictly, so one control kind this build did not know threw for the whole batch: nothing in it was answered and the worker waited out each request's timeout. Parse each request alone, answer an unknown kind 501 sim_control_unsupported and a malformed one 400, and keep running its batch-mates. Every poll now declares the kinds this build's generated contract parses, so the worker can stop sending kinds a receiver cannot handle.
1 parent 9411deb commit ae6d45b

3 files changed

Lines changed: 175 additions & 13 deletions

File tree

‎apps/sim/lib/mothership/generated/sim-transport.ts‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import { MemoryScopeRequest } from "./memory-scope";
77
import { RunControlRequest } from "./run-control";
88
import { TaskWakeRequest, WorkflowWatchRequest } from "./tasks";
99

10+
const SimChannelId = z.string().regex(/^[a-f0-9]{64}$/);
11+
1012
export const SimConnection = z.discriminatedUnion("mode", [
1113
z.object({ mode: z.literal("direct") }),
12-
z.object({ mode: z.literal("checkpoint"), channelId: z.string().regex(/^[a-f0-9]{64}$/) }),
14+
z.object({ mode: z.literal("checkpoint"), channelId: SimChannelId }),
1315
]);
1416
export type SimConnection = z.infer<typeof SimConnection>;
1517

@@ -51,9 +53,43 @@ export const SimControlResult = z.object({
5153
});
5254
export type SimControlResult = z.infer<typeof SimControlResult>;
5355

54-
export const SimChannelPoll = z.object({ channelId: z.string().regex(/^[a-f0-9]{64}$/) });
56+
export type SimControlKind = SimControlOperation["kind"];
57+
/** Every kind this build's union parses: what a receiver declares in `accepts`. */
58+
export const SIM_CONTROL_KINDS: SimControlKind[] = SimControlOperation.options.map(
59+
(option) => option.shape.kind.value,
60+
);
61+
62+
/** The v1.0.0 kinds every receiver parses without declaring them. Frozen: a poll without
63+
* `accepts` comes from a receiver that knows only these, so every later kind is negotiated. */
64+
export const SIM_CONTROL_BASELINE_KINDS = [
65+
"run_control",
66+
"workflow_status",
67+
"wake",
68+
"workspace_context",
69+
"integration_catalog",
70+
] as const satisfies readonly SimControlKind[];
71+
export type BaselineSimControlKind = (typeof SIM_CONTROL_BASELINE_KINDS)[number];
72+
export type NegotiatedSimControlKind = Exclude<SimControlKind, BaselineSimControlKind>;
73+
74+
/** Error code of the 501 answer for a kind the receiving Sim did not declare. */
75+
export const SIM_CONTROL_UNSUPPORTED = "sim_control_unsupported";
76+
77+
/** `accepts` is free-form so a newer Sim's kinds never fail an older worker's poll. */
78+
export const SimChannelPoll = z.object({
79+
channelId: SimChannelId,
80+
accepts: z.array(z.string().min(1).max(64)).max(64).optional(),
81+
});
82+
/** The worker's typed batch; receivers parse `SimChannelBatchEnvelope` instead. */
5583
export const SimChannelBatch = z.object({ requests: z.array(SimControlRequest).max(16) });
56-
export const SimChannelReply = SimChannelPoll.extend({
84+
/** Receivers parse each request alone, so one unknown kind never poisons its batch-mates. */
85+
export const SimChannelBatchEnvelope = z.object({ requests: z.array(z.unknown()).max(16) });
86+
/** Enough of a request to answer it when its operation does not parse. */
87+
export const SimControlRequestHeader = z.object({
88+
id: z.uuid(),
89+
operation: z.object({ kind: z.string() }).optional().catch(undefined),
90+
});
91+
export const SimChannelReply = z.object({
92+
channelId: SimChannelId,
5793
id: z.uuid(),
5894
result: SimControlResult,
5995
});

‎apps/sim/lib/mothership/transport/receiver.test.ts‎

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createDeferred } from '@sim/testing/helpers/deferred'
22
import { setEnv } from '@sim/testing/mocks/env.mock'
33
import { setEnvFlags } from '@sim/testing/mocks/env-flags.mock'
4+
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
45
import {
56
mothershipAgentUrlMock,
67
mothershipAgentUrlMockFns,
@@ -22,6 +23,7 @@ vi.mock('@/lib/mothership/transport/control', () => ({ executeSimControl: execut
2223
vi.mock('@/lib/mothership/server/agent-url', () => mothershipAgentUrlMock)
2324
vi.mock('@sim/utils/helpers', () => utilsHelpersMock)
2425

26+
import { SIM_CONTROL_KINDS } from '@/lib/mothership/generated/sim-transport'
2527
import { receiveSimControls, startSimReceivers } from '@/lib/mothership/transport/receiver'
2628

2729
const mocks = {
@@ -87,18 +89,92 @@ describe('outbound receiver lifecycle', () => {
8789
expect(mocks.sleep).toHaveBeenCalledOnce()
8890
})
8991

90-
it('does not execute malformed or redirected responses as Sim operations', async () => {
92+
it('does not execute redirected responses or requests it cannot read as Sim operations', async () => {
9193
const controller = new AbortController()
94+
const replies: string[] = []
9295
mocks.fetch
9396
.mockResolvedValueOnce(new Response(null, { status: 302 }))
9497
.mockResolvedValueOnce(Response.json({ requests: [{ operation: { kind: 'unknown' } }] }))
95-
.mockImplementationOnce(async () => {
98+
.mockImplementation(async (url: string, init: RequestInit) => {
99+
if (url.endsWith('/reply')) {
100+
replies.push(String(init.body))
101+
return Response.json({ accepted: true })
102+
}
96103
controller.abort()
97104
throw new Error('shutdown')
98105
})
99106
await receiveSimControls('https://worker.test', 'a'.repeat(64), controller.signal)
100107
expect(mocks.execute).not.toHaveBeenCalled()
101-
expect(mocks.sleep).toHaveBeenCalledTimes(2)
108+
expect(replies).toEqual([])
109+
expect(mocks.sleep).toHaveBeenCalledOnce()
110+
expect(getMockLogger('MothershipTransportReceiver').warn).toHaveBeenCalledWith(
111+
'Unreadable Sim control request dropped'
112+
)
113+
})
114+
115+
it('answers an unknown kind 501 and a malformed input 400 while their batch-mates still run', async () => {
116+
const controller = new AbortController()
117+
const valid = request()
118+
const unknown = { ...request(), operation: { kind: 'a_future_kind', input: {} } }
119+
const malformed = { ...request(), operation: { kind: 'run_control', input: { chatId: 7 } } }
120+
const kindless = { ...request(), operation: { input: {} } }
121+
const replies: { id: string; result: { status: number; body: string } }[] = []
122+
let polls = 0
123+
mocks.fetch.mockImplementation(async (url: string, init: RequestInit) => {
124+
if (url.endsWith('/reply')) {
125+
replies.push(JSON.parse(String(init.body)))
126+
return Response.json({ accepted: true })
127+
}
128+
polls++
129+
if (polls === 1) return Response.json({ requests: [unknown, valid, malformed, kindless] })
130+
controller.abort()
131+
throw new Error('shutdown')
132+
})
133+
await receiveSimControls('https://worker.test', 'a'.repeat(64), controller.signal)
134+
expect(mocks.execute).toHaveBeenCalledOnce()
135+
expect(mocks.execute).toHaveBeenCalledWith(valid)
136+
expect(replies).toHaveLength(4)
137+
expect(replies).toEqual(
138+
expect.arrayContaining([
139+
expect.objectContaining({
140+
id: valid.id,
141+
result: { status: 200, body: '{"stopped":false}' },
142+
}),
143+
expect.objectContaining({
144+
id: unknown.id,
145+
result: {
146+
status: 501,
147+
body: JSON.stringify({ error: 'sim_control_unsupported', kind: 'a_future_kind' }),
148+
},
149+
}),
150+
expect.objectContaining({
151+
id: malformed.id,
152+
result: { status: 400, body: '{"error":"Invalid Sim control request"}' },
153+
}),
154+
expect.objectContaining({
155+
id: kindless.id,
156+
result: { status: 400, body: '{"error":"Invalid Sim control request"}' },
157+
}),
158+
])
159+
)
160+
expect(mocks.sleep).not.toHaveBeenCalled()
161+
})
162+
163+
it('declares on every poll exactly the kinds its generated contract parses', async () => {
164+
const controller = new AbortController()
165+
const polls: unknown[] = []
166+
mocks.fetch.mockImplementation(async (_url: string, init: RequestInit) => {
167+
polls.push(JSON.parse(String(init.body)))
168+
if (polls.length === 1) throw new Error('connection lost')
169+
controller.abort()
170+
throw new Error('shutdown')
171+
})
172+
await receiveSimControls('https://worker.test', 'a'.repeat(64), controller.signal)
173+
expect(SIM_CONTROL_KINDS).toContain('memory_scope')
174+
expect(polls).toEqual([
175+
{ channelId: 'a'.repeat(64), accepts: SIM_CONTROL_KINDS },
176+
{ channelId: 'a'.repeat(64), accepts: SIM_CONTROL_KINDS },
177+
])
102178
})
103179

104180
it('does not repeat an operation when its reply acknowledgement is lost', async () => {

‎apps/sim/lib/mothership/transport/receiver.ts‎

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import { sleep } from '@sim/utils/helpers'
4+
import { truncate } from '@sim/utils/string'
45
import { env } from '@/lib/core/config/env'
56
import { isMothershipBenchmarkEnabled } from '@/lib/core/config/env-flags'
6-
import { SimChannelBatch, type SimConnection } from '@/lib/mothership/generated/sim-transport'
7+
import {
8+
SIM_CONTROL_KINDS,
9+
SIM_CONTROL_UNSUPPORTED,
10+
SimChannelBatchEnvelope,
11+
type SimConnection,
12+
SimControlRequest,
13+
SimControlRequestHeader,
14+
type SimControlResult,
15+
} from '@/lib/mothership/generated/sim-transport'
716
import { fetchGo } from '@/lib/mothership/request/go/fetch'
817
import { mothershipRequestHeaders } from '@/lib/mothership/request/headers'
918
import { getMothershipBaseURL } from '@/lib/mothership/server/agent-url'
@@ -13,17 +22,52 @@ import { executeSimControl } from '@/lib/mothership/transport/control'
1322
const logger = createLogger('MothershipTransportReceiver')
1423
const receivers = new Map<string, AbortController>()
1524

25+
interface ReceivedControl {
26+
id: string
27+
answer: () => Promise<SimControlResult>
28+
}
29+
30+
/** Each request parses alone, so one unknown or malformed request never costs its batch-mates. */
31+
function receiveControl(raw: unknown): ReceivedControl | null {
32+
const request = SimControlRequest.safeParse(raw)
33+
if (request.success) return { id: request.data.id, answer: () => executeSimControl(request.data) }
34+
const header = SimControlRequestHeader.safeParse(raw)
35+
if (!header.success) {
36+
logger.warn('Unreadable Sim control request dropped')
37+
return null
38+
}
39+
const { id, operation } = header.data
40+
const kind = operation && truncate(operation.kind, 64)
41+
if (operation && !SIM_CONTROL_KINDS.some((known) => known === operation.kind)) {
42+
logger.warn('Unsupported Sim control kind', { kind })
43+
const body = JSON.stringify({ error: SIM_CONTROL_UNSUPPORTED, kind })
44+
return { id, answer: async () => ({ status: 501, body }) }
45+
}
46+
logger.warn('Invalid Sim control request', {
47+
kind,
48+
issues: request.error.issues.map((issue) => issue.path.join('.')),
49+
})
50+
return {
51+
id,
52+
answer: async () => ({
53+
status: 400,
54+
body: JSON.stringify({ error: 'Invalid Sim control request' }),
55+
}),
56+
}
57+
}
58+
1659
export async function receiveSimControls(
1760
baseURL: string,
1861
channelId: string,
1962
signal: AbortSignal
2063
): Promise<void> {
2164
while (!signal.aborted) {
65+
let phase: 'poll' | 'parse' | 'reply' = 'poll'
2266
try {
2367
const response = await fetchGo(`${baseURL}/api/sim-transport/poll`, {
2468
method: 'POST',
2569
headers: mothershipRequestHeaders(),
26-
body: JSON.stringify({ channelId }),
70+
body: JSON.stringify({ channelId, accepts: SIM_CONTROL_KINDS }),
2771
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
2872
redirect: 'error',
2973
spanName: 'sim → worker transport poll',
@@ -33,15 +77,18 @@ export async function receiveSimControls(
3377
await response.body?.cancel()
3478
throw new Error(`Transport poll refused (HTTP ${response.status})`)
3579
}
36-
const batch = SimChannelBatch.parse(await response.json())
80+
phase = 'parse'
81+
const batch = SimChannelBatchEnvelope.parse(await response.json())
82+
const controls = batch.requests.flatMap((raw) => receiveControl(raw) ?? [])
83+
phase = 'reply'
3784
const replies = await Promise.allSettled(
38-
batch.requests.map(async (request) => {
85+
controls.map(async (control) => {
3986
signal.throwIfAborted()
40-
const result = await executeSimControl(request)
87+
const result = await control.answer()
4188
const reply = await fetchGo(`${baseURL}/api/sim-transport/reply`, {
4289
method: 'POST',
4390
headers: mothershipRequestHeaders(),
44-
body: JSON.stringify({ channelId, id: request.id, result }),
91+
body: JSON.stringify({ channelId, id: control.id, result }),
4592
redirect: 'error',
4693
signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]),
4794
spanName: 'sim → worker transport reply',
@@ -56,7 +103,10 @@ export async function receiveSimControls(
56103
if (failed?.status === 'rejected') throw failed.reason
57104
} catch (error) {
58105
if (signal.aborted) return
59-
logger.warn('Sim transport disconnected; reconnecting', { error: getErrorMessage(error) })
106+
logger.warn('Sim transport disconnected; reconnecting', {
107+
phase,
108+
error: getErrorMessage(error),
109+
})
60110
await sleep(1000)
61111
}
62112
}

0 commit comments

Comments
 (0)