@forgeax/engine-intelligence 0.1.6 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -3
- package/dist/.tsbuildinfo +1 -1
- package/dist/index.mjs +98 -31
- package/dist/index.mjs.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/transport.d.ts +3 -0
- package/dist/transport.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/runtime.test.ts +711 -0
- package/src/__tests__/transport.test.ts +1435 -0
- package/src/runtime.ts +26 -8
- package/src/transport.ts +79 -23
|
@@ -4,7 +4,9 @@ import { createIntelligenceRuntime } from '../runtime';
|
|
|
4
4
|
import {
|
|
5
5
|
bindIntelligencePort,
|
|
6
6
|
createIntelligencePortClient,
|
|
7
|
+
type IntelligenceHostCommand,
|
|
7
8
|
type IntelligenceMessagePort,
|
|
9
|
+
type IntelligenceRealmMessage,
|
|
8
10
|
} from '../transport';
|
|
9
11
|
import { type ActivitySink, activityId, type IntelligenceProvider } from '../types';
|
|
10
12
|
|
|
@@ -127,4 +129,1437 @@ describe('intelligence MessagePort boundary', () => {
|
|
|
127
129
|
await client.close();
|
|
128
130
|
await host.close();
|
|
129
131
|
});
|
|
132
|
+
|
|
133
|
+
it('recovers optimistic capacity after an asynchronous Host rejection', async () => {
|
|
134
|
+
const sinks = new Map<string, ActivitySink>();
|
|
135
|
+
const starts: string[] = [];
|
|
136
|
+
const provider: IntelligenceProvider = {
|
|
137
|
+
id: 'host.limit.drift.provider',
|
|
138
|
+
start(submission, sink) {
|
|
139
|
+
starts.push(submission.input);
|
|
140
|
+
sinks.set(String(submission.id), sink);
|
|
141
|
+
return ok(undefined);
|
|
142
|
+
},
|
|
143
|
+
cancel(id) {
|
|
144
|
+
sinks.get(String(id))?.cancelled();
|
|
145
|
+
sinks.delete(String(id));
|
|
146
|
+
return ok(undefined);
|
|
147
|
+
},
|
|
148
|
+
async close() {},
|
|
149
|
+
};
|
|
150
|
+
const runtime = createIntelligenceRuntime(provider, {
|
|
151
|
+
limits: { maxConcurrentActivities: 1 },
|
|
152
|
+
});
|
|
153
|
+
const channel = new MessageChannel();
|
|
154
|
+
const host = bindIntelligencePort(channel.port1 as unknown as IntelligenceMessagePort, runtime);
|
|
155
|
+
let identity = 0;
|
|
156
|
+
const client = createIntelligencePortClient(
|
|
157
|
+
provider.id,
|
|
158
|
+
channel.port2 as unknown as IntelligenceMessagePort,
|
|
159
|
+
{
|
|
160
|
+
limits: { maxConcurrentActivities: 2 },
|
|
161
|
+
createActivityId: () => {
|
|
162
|
+
identity += 1;
|
|
163
|
+
return activityId(`host-limit-drift-${identity}`);
|
|
164
|
+
},
|
|
165
|
+
createSessionId: () => 'host-limit-drift-session',
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const first = client.submit({ input: 'keep-live' });
|
|
171
|
+
const rejected = client.submit({ input: 'host-reject' });
|
|
172
|
+
expect(first.ok).toBe(true);
|
|
173
|
+
expect(rejected.ok).toBe(true);
|
|
174
|
+
if (!first.ok || !rejected.ok) return;
|
|
175
|
+
|
|
176
|
+
const rejectionEvents: ReturnType<typeof client.poll>[number][] = [];
|
|
177
|
+
for (let attempt = 0; attempt < 40 && rejectionEvents.length === 0; attempt += 1) {
|
|
178
|
+
rejectionEvents.push(
|
|
179
|
+
...client.poll().filter((event) => event.activityId === rejected.value.id),
|
|
180
|
+
);
|
|
181
|
+
if (rejectionEvents.length === 0) await new Promise((resolve) => setTimeout(resolve, 5));
|
|
182
|
+
}
|
|
183
|
+
expect(starts).toEqual(['keep-live']);
|
|
184
|
+
expect(rejectionEvents).toHaveLength(1);
|
|
185
|
+
expect(rejectionEvents[0]).toEqual({
|
|
186
|
+
type: 'failed',
|
|
187
|
+
activityId: rejected.value.id,
|
|
188
|
+
sequence: 1,
|
|
189
|
+
error: expect.objectContaining({
|
|
190
|
+
code: 'intelligence-capacity-exceeded',
|
|
191
|
+
detail: { limit: 1 },
|
|
192
|
+
}),
|
|
193
|
+
});
|
|
194
|
+
const cancelRejected = client.cancel(rejected.value.id);
|
|
195
|
+
expect(cancelRejected.ok).toBe(false);
|
|
196
|
+
if (!cancelRejected.ok)
|
|
197
|
+
expect(cancelRejected.error.code).toBe('intelligence-activity-not-found');
|
|
198
|
+
|
|
199
|
+
sinks.get(String(first.value.id))?.complete('keep-live-ok');
|
|
200
|
+
let firstEvent: ReturnType<typeof client.poll>[number] | undefined;
|
|
201
|
+
for (let attempt = 0; attempt < 40 && firstEvent === undefined; attempt += 1) {
|
|
202
|
+
firstEvent = client.poll().find((event) => event.activityId === first.value.id);
|
|
203
|
+
if (firstEvent === undefined) await new Promise((resolve) => setTimeout(resolve, 5));
|
|
204
|
+
}
|
|
205
|
+
expect(firstEvent).toEqual(
|
|
206
|
+
expect.objectContaining({
|
|
207
|
+
type: 'completed',
|
|
208
|
+
activityId: first.value.id,
|
|
209
|
+
output: 'keep-live-ok',
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const retry = client.submit({ input: 'retry', session: rejected.value.session });
|
|
214
|
+
expect(retry.ok).toBe(true);
|
|
215
|
+
if (!retry.ok) return;
|
|
216
|
+
expect(retry.value.session).toEqual(rejected.value.session);
|
|
217
|
+
|
|
218
|
+
for (let attempt = 0; attempt < 40 && !starts.includes('retry'); attempt += 1) {
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
220
|
+
}
|
|
221
|
+
expect(starts).toEqual(['keep-live', 'retry']);
|
|
222
|
+
sinks.get(String(retry.value.id))?.complete('retry-ok');
|
|
223
|
+
|
|
224
|
+
let retryEvent: ReturnType<typeof client.poll>[number] | undefined;
|
|
225
|
+
for (let attempt = 0; attempt < 40 && retryEvent === undefined; attempt += 1) {
|
|
226
|
+
retryEvent = client.poll().find((event) => event.activityId === retry.value.id);
|
|
227
|
+
if (retryEvent === undefined) await new Promise((resolve) => setTimeout(resolve, 5));
|
|
228
|
+
}
|
|
229
|
+
expect(retryEvent).toEqual(
|
|
230
|
+
expect.objectContaining({
|
|
231
|
+
type: 'completed',
|
|
232
|
+
activityId: retry.value.id,
|
|
233
|
+
session: rejected.value.session,
|
|
234
|
+
output: 'retry-ok',
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
} finally {
|
|
238
|
+
await client.close();
|
|
239
|
+
await host.close();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('rejects a cross-provider SessionRef before transport or identity side effects', async () => {
|
|
244
|
+
const createProvider = (id: string) => {
|
|
245
|
+
const sinks = new Map<string, ActivitySink>();
|
|
246
|
+
const starts: string[] = [];
|
|
247
|
+
let closeCalls = 0;
|
|
248
|
+
let closed = false;
|
|
249
|
+
const provider: IntelligenceProvider = {
|
|
250
|
+
id,
|
|
251
|
+
start(submission, sink) {
|
|
252
|
+
starts.push(submission.input);
|
|
253
|
+
sinks.set(String(submission.id), sink);
|
|
254
|
+
return ok(undefined);
|
|
255
|
+
},
|
|
256
|
+
cancel(activity) {
|
|
257
|
+
sinks.get(String(activity))?.cancelled();
|
|
258
|
+
sinks.delete(String(activity));
|
|
259
|
+
return ok(undefined);
|
|
260
|
+
},
|
|
261
|
+
async close() {
|
|
262
|
+
closeCalls += 1;
|
|
263
|
+
closed = true;
|
|
264
|
+
sinks.clear();
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
return {
|
|
268
|
+
provider,
|
|
269
|
+
sinks,
|
|
270
|
+
starts,
|
|
271
|
+
get closeCalls() {
|
|
272
|
+
return closeCalls;
|
|
273
|
+
},
|
|
274
|
+
get closed() {
|
|
275
|
+
return closed;
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
const providerA = createProvider('transport.provider.a');
|
|
280
|
+
const providerB = createProvider('transport.provider.b');
|
|
281
|
+
const channelA = new MessageChannel();
|
|
282
|
+
const channelB = new MessageChannel();
|
|
283
|
+
const submittedA: string[] = [];
|
|
284
|
+
const submittedB: string[] = [];
|
|
285
|
+
const observeSubmissions = (port: MessagePort, target: string[]): void => {
|
|
286
|
+
port.addEventListener('message', (event: MessageEvent<unknown>) => {
|
|
287
|
+
const message = event.data;
|
|
288
|
+
if (typeof message !== 'object' || message === null) return;
|
|
289
|
+
const value = message as {
|
|
290
|
+
readonly kind?: unknown;
|
|
291
|
+
readonly submission?: { readonly input?: unknown };
|
|
292
|
+
};
|
|
293
|
+
if (value.kind === 'intelligence-submit' && typeof value.submission?.input === 'string') {
|
|
294
|
+
target.push(value.submission.input);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
port.start();
|
|
298
|
+
};
|
|
299
|
+
observeSubmissions(channelA.port1, submittedA);
|
|
300
|
+
observeSubmissions(channelB.port1, submittedB);
|
|
301
|
+
const hostA = bindIntelligencePort(
|
|
302
|
+
channelA.port1 as unknown as IntelligenceMessagePort,
|
|
303
|
+
createIntelligenceRuntime(providerA.provider, { limits: { maxConcurrentActivities: 1 } }),
|
|
304
|
+
);
|
|
305
|
+
const hostB = bindIntelligencePort(
|
|
306
|
+
channelB.port1 as unknown as IntelligenceMessagePort,
|
|
307
|
+
createIntelligenceRuntime(providerB.provider, { limits: { maxConcurrentActivities: 1 } }),
|
|
308
|
+
);
|
|
309
|
+
let activityCallsA = 0;
|
|
310
|
+
let sessionCallsA = 0;
|
|
311
|
+
let activityCallsB = 0;
|
|
312
|
+
let sessionCallsB = 0;
|
|
313
|
+
const clientA = createIntelligencePortClient(
|
|
314
|
+
providerA.provider.id,
|
|
315
|
+
channelA.port2 as unknown as IntelligenceMessagePort,
|
|
316
|
+
{
|
|
317
|
+
createActivityId: () => activityId(`transport-a-${++activityCallsA}`),
|
|
318
|
+
createSessionId: () => `transport-a-session-${++sessionCallsA}`,
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
const clientB = createIntelligencePortClient(
|
|
322
|
+
providerB.provider.id,
|
|
323
|
+
channelB.port2 as unknown as IntelligenceMessagePort,
|
|
324
|
+
{
|
|
325
|
+
limits: { maxConcurrentActivities: 1 },
|
|
326
|
+
createActivityId: () => activityId(`transport-b-${++activityCallsB}`),
|
|
327
|
+
createSessionId: () => `transport-b-session-${++sessionCallsB}`,
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
const waitForEvent = async (
|
|
331
|
+
client: typeof clientA,
|
|
332
|
+
predicate: (event: ReturnType<typeof clientA.poll>[number]) => boolean,
|
|
333
|
+
): Promise<ReturnType<typeof clientA.poll>[number] | undefined> => {
|
|
334
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
335
|
+
const event = client.poll().find(predicate);
|
|
336
|
+
if (event !== undefined) return event;
|
|
337
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
338
|
+
}
|
|
339
|
+
return undefined;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
const first = clientA.submit({ input: 'a-first' });
|
|
344
|
+
expect(first.ok).toBe(true);
|
|
345
|
+
if (!first.ok) return;
|
|
346
|
+
for (let attempt = 0; attempt < 40 && providerA.starts.length === 0; attempt += 1) {
|
|
347
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
348
|
+
}
|
|
349
|
+
expect(providerA.starts).toEqual(['a-first']);
|
|
350
|
+
providerA.sinks.get(String(first.value.id))?.complete('a-first-ok');
|
|
351
|
+
expect(
|
|
352
|
+
(await waitForEvent(clientA, (event) => event.activityId === first.value.id))?.type,
|
|
353
|
+
).toBe('completed');
|
|
354
|
+
|
|
355
|
+
const mismatch = clientB.submit({ input: 'b-mismatch', session: first.value.session });
|
|
356
|
+
expect(mismatch.ok).toBe(false);
|
|
357
|
+
if (!mismatch.ok) {
|
|
358
|
+
expect(mismatch.error.code).toBe('intelligence-session-provider-mismatch');
|
|
359
|
+
expect(mismatch.error.detail).toEqual({
|
|
360
|
+
expectedProviderId: providerB.provider.id,
|
|
361
|
+
receivedProviderId: providerA.provider.id,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
expect(activityCallsB).toBe(0);
|
|
365
|
+
expect(sessionCallsB).toBe(0);
|
|
366
|
+
expect(submittedB).toEqual([]);
|
|
367
|
+
expect(providerB.starts).toEqual([]);
|
|
368
|
+
|
|
369
|
+
const validB = clientB.submit({ input: 'b-valid' });
|
|
370
|
+
expect(validB.ok).toBe(true);
|
|
371
|
+
if (!validB.ok) return;
|
|
372
|
+
for (let attempt = 0; attempt < 40 && providerB.starts.length === 0; attempt += 1) {
|
|
373
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
374
|
+
}
|
|
375
|
+
expect(providerB.starts).toEqual(['b-valid']);
|
|
376
|
+
providerB.sinks.get(String(validB.value.id))?.complete('b-valid-ok');
|
|
377
|
+
expect(
|
|
378
|
+
(await waitForEvent(clientB, (event) => event.activityId === validB.value.id))?.type,
|
|
379
|
+
).toBe('completed');
|
|
380
|
+
|
|
381
|
+
const retryA = clientA.submit({ input: 'a-retry', session: first.value.session });
|
|
382
|
+
expect(retryA.ok).toBe(true);
|
|
383
|
+
if (!retryA.ok) return;
|
|
384
|
+
expect(retryA.value.session).toEqual(first.value.session);
|
|
385
|
+
for (let attempt = 0; attempt < 40 && providerA.starts.length < 2; attempt += 1) {
|
|
386
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
387
|
+
}
|
|
388
|
+
expect(providerA.starts).toEqual(['a-first', 'a-retry']);
|
|
389
|
+
providerA.sinks.get(String(retryA.value.id))?.complete('a-retry-ok');
|
|
390
|
+
expect(
|
|
391
|
+
(await waitForEvent(clientA, (event) => event.activityId === retryA.value.id))?.type,
|
|
392
|
+
).toBe('completed');
|
|
393
|
+
|
|
394
|
+
expect(activityCallsA).toBe(2);
|
|
395
|
+
expect(sessionCallsA).toBe(1);
|
|
396
|
+
expect(activityCallsB).toBe(1);
|
|
397
|
+
expect(sessionCallsB).toBe(1);
|
|
398
|
+
expect(submittedA).toEqual(['a-first', 'a-retry']);
|
|
399
|
+
expect(submittedB).toEqual(['b-valid']);
|
|
400
|
+
} finally {
|
|
401
|
+
await Promise.all([clientA.close(), clientB.close()]);
|
|
402
|
+
await Promise.all([hostA.close(), hostB.close()]);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
expect(providerA.closeCalls).toBe(1);
|
|
406
|
+
expect(providerB.closeCalls).toBe(1);
|
|
407
|
+
expect(providerA.sinks).toHaveLength(0);
|
|
408
|
+
expect(providerB.sinks).toHaveLength(0);
|
|
409
|
+
expect(providerA.closed).toBe(true);
|
|
410
|
+
expect(providerB.closed).toBe(true);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('drains an interleaved backlog with one poll credit and releases terminal capacity', async () => {
|
|
414
|
+
const sinks = new Map<string, ActivitySink>();
|
|
415
|
+
const starts: string[] = [];
|
|
416
|
+
const provider: IntelligenceProvider = {
|
|
417
|
+
id: 'port.poll.credit.provider',
|
|
418
|
+
start(submission, sink) {
|
|
419
|
+
starts.push(submission.input);
|
|
420
|
+
sinks.set(String(submission.id), sink);
|
|
421
|
+
return ok(undefined);
|
|
422
|
+
},
|
|
423
|
+
cancel(id) {
|
|
424
|
+
sinks.get(String(id))?.cancelled();
|
|
425
|
+
return ok(undefined);
|
|
426
|
+
},
|
|
427
|
+
async close() {},
|
|
428
|
+
};
|
|
429
|
+
const runtime = createIntelligenceRuntime(provider, {
|
|
430
|
+
limits: { maxConcurrentActivities: 2, maxPollEvents: 1 },
|
|
431
|
+
});
|
|
432
|
+
const channel = new MessageChannel();
|
|
433
|
+
const rawHostPort = channel.port1;
|
|
434
|
+
let pollRequests = 0;
|
|
435
|
+
let pollResponses = 0;
|
|
436
|
+
let pollOutstanding = 0;
|
|
437
|
+
let maxPollOutstanding = 0;
|
|
438
|
+
rawHostPort.addEventListener('message', (event: MessageEvent<unknown>) => {
|
|
439
|
+
const message = event.data;
|
|
440
|
+
if (typeof message !== 'object' || message === null) return;
|
|
441
|
+
if ((message as { readonly kind?: unknown }).kind !== 'intelligence-poll') return;
|
|
442
|
+
pollRequests += 1;
|
|
443
|
+
pollOutstanding += 1;
|
|
444
|
+
maxPollOutstanding = Math.max(maxPollOutstanding, pollOutstanding);
|
|
445
|
+
});
|
|
446
|
+
const hostPort: IntelligenceMessagePort = {
|
|
447
|
+
postMessage(message) {
|
|
448
|
+
if (message.kind === 'intelligence-events') {
|
|
449
|
+
pollResponses += 1;
|
|
450
|
+
pollOutstanding -= 1;
|
|
451
|
+
}
|
|
452
|
+
rawHostPort.postMessage(message);
|
|
453
|
+
},
|
|
454
|
+
addEventListener(type, listener) {
|
|
455
|
+
rawHostPort.addEventListener(type, listener as EventListener);
|
|
456
|
+
},
|
|
457
|
+
removeEventListener(type, listener) {
|
|
458
|
+
rawHostPort.removeEventListener(type, listener as EventListener);
|
|
459
|
+
},
|
|
460
|
+
start() {
|
|
461
|
+
rawHostPort.start();
|
|
462
|
+
},
|
|
463
|
+
close() {
|
|
464
|
+
rawHostPort.close();
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
const host = bindIntelligencePort(hostPort, runtime);
|
|
468
|
+
const client = createIntelligencePortClient(
|
|
469
|
+
provider.id,
|
|
470
|
+
channel.port2 as unknown as IntelligenceMessagePort,
|
|
471
|
+
{
|
|
472
|
+
limits: { maxConcurrentActivities: 2, maxPollEvents: 1 },
|
|
473
|
+
createActivityId: (() => {
|
|
474
|
+
let identity = 0;
|
|
475
|
+
return () => activityId(`poll-credit-${++identity}`);
|
|
476
|
+
})(),
|
|
477
|
+
},
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
const first = client.submit({ input: 'first' });
|
|
482
|
+
const second = client.submit({ input: 'second' });
|
|
483
|
+
expect(first.ok).toBe(true);
|
|
484
|
+
expect(second.ok).toBe(true);
|
|
485
|
+
if (!first.ok || !second.ok) return;
|
|
486
|
+
|
|
487
|
+
for (let attempt = 0; attempt < 40 && sinks.size < 2; attempt += 1) {
|
|
488
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
489
|
+
}
|
|
490
|
+
expect(starts).toEqual(['first', 'second']);
|
|
491
|
+
const firstSink = sinks.get(String(first.value.id));
|
|
492
|
+
const secondSink = sinks.get(String(second.value.id));
|
|
493
|
+
expect(firstSink).toBeDefined();
|
|
494
|
+
expect(secondSink).toBeDefined();
|
|
495
|
+
if (firstSink === undefined || secondSink === undefined) return;
|
|
496
|
+
|
|
497
|
+
const emitted: string[] = [];
|
|
498
|
+
emitted.push('first:text-1');
|
|
499
|
+
firstSink.text('first-1');
|
|
500
|
+
emitted.push('second:text-1');
|
|
501
|
+
secondSink.text('second-1');
|
|
502
|
+
emitted.push('first:text-2');
|
|
503
|
+
firstSink.text('first-2');
|
|
504
|
+
emitted.push('second:text-2');
|
|
505
|
+
secondSink.text('second-2');
|
|
506
|
+
emitted.push('first:terminal');
|
|
507
|
+
firstSink.complete('first-ok');
|
|
508
|
+
emitted.push('second:terminal');
|
|
509
|
+
secondSink.complete('second-ok');
|
|
510
|
+
expect(emitted).toEqual([
|
|
511
|
+
'first:text-1',
|
|
512
|
+
'second:text-1',
|
|
513
|
+
'first:text-2',
|
|
514
|
+
'second:text-2',
|
|
515
|
+
'first:terminal',
|
|
516
|
+
'second:terminal',
|
|
517
|
+
]);
|
|
518
|
+
|
|
519
|
+
client.poll();
|
|
520
|
+
client.poll();
|
|
521
|
+
for (let attempt = 0; attempt < 40 && pollRequests < 1; attempt += 1) {
|
|
522
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
523
|
+
}
|
|
524
|
+
expect(pollRequests).toBe(1);
|
|
525
|
+
expect(maxPollOutstanding).toBe(1);
|
|
526
|
+
|
|
527
|
+
const received: ReturnType<typeof client.poll>[number][] = [];
|
|
528
|
+
for (let attempt = 0; attempt < 200 && received.length < 6; attempt += 1) {
|
|
529
|
+
received.push(...client.poll());
|
|
530
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
531
|
+
}
|
|
532
|
+
expect(received).toHaveLength(6);
|
|
533
|
+
for (const id of [first.value.id, second.value.id]) {
|
|
534
|
+
const activityEvents = received.filter((event) => event.activityId === id);
|
|
535
|
+
expect(activityEvents.map((event) => event.sequence)).toEqual([1, 2, 3]);
|
|
536
|
+
expect(activityEvents.at(-1)?.type).toBe('completed');
|
|
537
|
+
}
|
|
538
|
+
expect(new Set(received.map((event) => String(event.activityId)))).toEqual(
|
|
539
|
+
new Set([String(first.value.id), String(second.value.id)]),
|
|
540
|
+
);
|
|
541
|
+
for (let attempt = 0; attempt < 40 && pollOutstanding !== 0; attempt += 1) {
|
|
542
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
543
|
+
}
|
|
544
|
+
expect(pollOutstanding).toBe(0);
|
|
545
|
+
expect(pollResponses).toBe(pollRequests);
|
|
546
|
+
expect(maxPollOutstanding).toBe(1);
|
|
547
|
+
|
|
548
|
+
const requestsBeforeRecovery = pollRequests;
|
|
549
|
+
const after = client.submit({ input: 'after terminal' });
|
|
550
|
+
expect(after.ok).toBe(true);
|
|
551
|
+
if (!after.ok) return;
|
|
552
|
+
for (let attempt = 0; attempt < 40 && !starts.includes('after terminal'); attempt += 1) {
|
|
553
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
554
|
+
}
|
|
555
|
+
expect(starts).toEqual(['first', 'second', 'after terminal']);
|
|
556
|
+
sinks.get(String(after.value.id))?.complete('after-ok');
|
|
557
|
+
|
|
558
|
+
const afterEvents: ReturnType<typeof client.poll>[number][] = [];
|
|
559
|
+
for (let attempt = 0; attempt < 100 && afterEvents.length === 0; attempt += 1) {
|
|
560
|
+
afterEvents.push(...client.poll());
|
|
561
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
562
|
+
}
|
|
563
|
+
expect(afterEvents).toEqual([
|
|
564
|
+
expect.objectContaining({
|
|
565
|
+
type: 'completed',
|
|
566
|
+
activityId: after.value.id,
|
|
567
|
+
sequence: 1,
|
|
568
|
+
output: 'after-ok',
|
|
569
|
+
}),
|
|
570
|
+
]);
|
|
571
|
+
for (let attempt = 0; attempt < 40 && pollOutstanding !== 0; attempt += 1) {
|
|
572
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
573
|
+
}
|
|
574
|
+
expect(pollOutstanding).toBe(0);
|
|
575
|
+
expect(pollRequests - requestsBeforeRecovery).toBe(2);
|
|
576
|
+
expect(maxPollOutstanding).toBe(1);
|
|
577
|
+
} finally {
|
|
578
|
+
await client.close();
|
|
579
|
+
await host.close();
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('rejects invalid input synchronously before identity, capacity, port dispatch, or provider start', async () => {
|
|
584
|
+
const sinks = new Map<string, ActivitySink>();
|
|
585
|
+
const submittedInputs: string[] = [];
|
|
586
|
+
let activityIdentityCalls = 0;
|
|
587
|
+
let sessionIdentityCalls = 0;
|
|
588
|
+
let providerStarts = 0;
|
|
589
|
+
const provider: IntelligenceProvider = {
|
|
590
|
+
id: 'port.input.boundary.provider',
|
|
591
|
+
start(submission, sink) {
|
|
592
|
+
providerStarts += 1;
|
|
593
|
+
sinks.set(submission.id, sink);
|
|
594
|
+
return ok(undefined);
|
|
595
|
+
},
|
|
596
|
+
cancel() {
|
|
597
|
+
return ok(undefined);
|
|
598
|
+
},
|
|
599
|
+
async close() {},
|
|
600
|
+
};
|
|
601
|
+
const runtime = createIntelligenceRuntime(provider, {
|
|
602
|
+
limits: { maxInputChars: 4, maxConcurrentActivities: 1 },
|
|
603
|
+
});
|
|
604
|
+
const channel = new MessageChannel();
|
|
605
|
+
channel.port1.addEventListener('message', (event: MessageEvent<unknown>) => {
|
|
606
|
+
const message = event.data;
|
|
607
|
+
if (typeof message !== 'object' || message === null) return;
|
|
608
|
+
const value = message as {
|
|
609
|
+
readonly kind?: unknown;
|
|
610
|
+
readonly submission?: { readonly input?: unknown };
|
|
611
|
+
};
|
|
612
|
+
if (value.kind === 'intelligence-submit' && typeof value.submission?.input === 'string') {
|
|
613
|
+
submittedInputs.push(value.submission.input);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
const host = bindIntelligencePort(channel.port1 as unknown as IntelligenceMessagePort, runtime);
|
|
617
|
+
const client = createIntelligencePortClient(
|
|
618
|
+
'port.input.boundary.provider',
|
|
619
|
+
channel.port2 as unknown as IntelligenceMessagePort,
|
|
620
|
+
{
|
|
621
|
+
limits: { maxInputChars: 4, maxConcurrentActivities: 1 },
|
|
622
|
+
createActivityId: () => {
|
|
623
|
+
activityIdentityCalls += 1;
|
|
624
|
+
return activityId('port-input-boundary-activity');
|
|
625
|
+
},
|
|
626
|
+
createSessionId: () => {
|
|
627
|
+
sessionIdentityCalls += 1;
|
|
628
|
+
return 'port-input-boundary-session';
|
|
629
|
+
},
|
|
630
|
+
},
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
const exact = client.submit({ input: 'abcd' });
|
|
634
|
+
expect(exact.ok).toBe(true);
|
|
635
|
+
if (!exact.ok) return;
|
|
636
|
+
expect(activityIdentityCalls).toBe(1);
|
|
637
|
+
expect(sessionIdentityCalls).toBe(1);
|
|
638
|
+
expect(providerStarts).toBe(0);
|
|
639
|
+
|
|
640
|
+
const empty = client.submit({ input: '' });
|
|
641
|
+
expect(empty.ok).toBe(false);
|
|
642
|
+
if (!empty.ok) {
|
|
643
|
+
expect(empty.error.code).toBe('intelligence-invalid-request');
|
|
644
|
+
expect(empty.error.detail).toEqual({ field: 'input', reason: 'input is empty' });
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const overLimit = client.submit({ input: 'abcde' });
|
|
648
|
+
expect(overLimit.ok).toBe(false);
|
|
649
|
+
if (!overLimit.ok) {
|
|
650
|
+
expect(overLimit.error.code).toBe('intelligence-invalid-request');
|
|
651
|
+
expect(overLimit.error.detail).toEqual({
|
|
652
|
+
field: 'input',
|
|
653
|
+
reason: 'input exceeds 4 characters',
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
expect(activityIdentityCalls).toBe(1);
|
|
658
|
+
expect(sessionIdentityCalls).toBe(1);
|
|
659
|
+
expect(providerStarts).toBe(0);
|
|
660
|
+
|
|
661
|
+
for (let attempt = 0; attempt < 10 && providerStarts === 0; attempt += 1) {
|
|
662
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
663
|
+
}
|
|
664
|
+
expect(providerStarts).toBe(1);
|
|
665
|
+
expect(submittedInputs).toEqual(['abcd']);
|
|
666
|
+
sinks.get(exact.value.id)?.complete('done');
|
|
667
|
+
|
|
668
|
+
let received: readonly ReturnType<typeof client.poll>[number][] = [];
|
|
669
|
+
for (let attempt = 0; attempt < 10 && received.length === 0; attempt += 1) {
|
|
670
|
+
received = client.poll();
|
|
671
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
672
|
+
}
|
|
673
|
+
expect(received).toEqual([
|
|
674
|
+
{
|
|
675
|
+
type: 'completed',
|
|
676
|
+
activityId: exact.value.id,
|
|
677
|
+
sequence: 1,
|
|
678
|
+
session: exact.value.session,
|
|
679
|
+
output: 'done',
|
|
680
|
+
},
|
|
681
|
+
]);
|
|
682
|
+
|
|
683
|
+
await client.close();
|
|
684
|
+
await host.close();
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('settles client close after a rejecting Host provider and permits a fresh binding', async () => {
|
|
688
|
+
let closeCalls = 0;
|
|
689
|
+
let starts = 0;
|
|
690
|
+
const provider: IntelligenceProvider = {
|
|
691
|
+
id: 'port.close.rejection.provider',
|
|
692
|
+
start(_submission, sink) {
|
|
693
|
+
starts += 1;
|
|
694
|
+
sink.complete('closed-run');
|
|
695
|
+
return ok(undefined);
|
|
696
|
+
},
|
|
697
|
+
cancel() {
|
|
698
|
+
return ok(undefined);
|
|
699
|
+
},
|
|
700
|
+
async close() {
|
|
701
|
+
closeCalls += 1;
|
|
702
|
+
throw new Error('sentinel Host close rejection');
|
|
703
|
+
},
|
|
704
|
+
};
|
|
705
|
+
const runtime = createIntelligenceRuntime(provider);
|
|
706
|
+
const channel = new MessageChannel();
|
|
707
|
+
const host = bindIntelligencePort(channel.port1 as unknown as IntelligenceMessagePort, runtime);
|
|
708
|
+
const client = createIntelligencePortClient(
|
|
709
|
+
'port.close.rejection.provider',
|
|
710
|
+
channel.port2 as unknown as IntelligenceMessagePort,
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
try {
|
|
714
|
+
expect(client.submit({ input: 'first binding' }).ok).toBe(true);
|
|
715
|
+
for (let attempt = 0; attempt < 20 && starts === 0; attempt += 1) {
|
|
716
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
717
|
+
}
|
|
718
|
+
expect(starts).toBe(1);
|
|
719
|
+
|
|
720
|
+
const closeSettled = await Promise.race([
|
|
721
|
+
client.close().then(
|
|
722
|
+
() => true,
|
|
723
|
+
() => false,
|
|
724
|
+
),
|
|
725
|
+
new Promise<false>((resolve) => setTimeout(() => resolve(false), 250)),
|
|
726
|
+
]);
|
|
727
|
+
expect(closeSettled).toBe(true);
|
|
728
|
+
expect(closeCalls).toBe(1);
|
|
729
|
+
|
|
730
|
+
const freshChannel = new MessageChannel();
|
|
731
|
+
const freshProvider: IntelligenceProvider = {
|
|
732
|
+
id: 'port.close.recovery.provider',
|
|
733
|
+
start(_submission, sink) {
|
|
734
|
+
sink.complete('fresh-run');
|
|
735
|
+
return ok(undefined);
|
|
736
|
+
},
|
|
737
|
+
cancel() {
|
|
738
|
+
return ok(undefined);
|
|
739
|
+
},
|
|
740
|
+
async close() {},
|
|
741
|
+
};
|
|
742
|
+
const freshHost = bindIntelligencePort(
|
|
743
|
+
freshChannel.port1 as unknown as IntelligenceMessagePort,
|
|
744
|
+
createIntelligenceRuntime(freshProvider),
|
|
745
|
+
);
|
|
746
|
+
const freshClient = createIntelligencePortClient(
|
|
747
|
+
'port.close.recovery.provider',
|
|
748
|
+
freshChannel.port2 as unknown as IntelligenceMessagePort,
|
|
749
|
+
);
|
|
750
|
+
try {
|
|
751
|
+
const freshStarted = freshClient.submit({ input: 'fresh binding' });
|
|
752
|
+
expect(freshStarted.ok).toBe(true);
|
|
753
|
+
let freshEvents: readonly ReturnType<typeof freshClient.poll>[number][] = [];
|
|
754
|
+
for (let attempt = 0; attempt < 20 && freshEvents.length === 0; attempt += 1) {
|
|
755
|
+
freshEvents = freshClient.poll();
|
|
756
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
757
|
+
}
|
|
758
|
+
expect(freshEvents).toEqual([
|
|
759
|
+
expect.objectContaining({ type: 'completed', output: 'fresh-run' }),
|
|
760
|
+
]);
|
|
761
|
+
await freshClient.close();
|
|
762
|
+
} finally {
|
|
763
|
+
await freshHost.close().catch(() => undefined);
|
|
764
|
+
}
|
|
765
|
+
} finally {
|
|
766
|
+
await host.close().catch(() => undefined);
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it('contains a synchronous Worker client close command failure and releases local transport', async () => {
|
|
771
|
+
let starts = 0;
|
|
772
|
+
let closeCalls = 0;
|
|
773
|
+
const provider: IntelligenceProvider = {
|
|
774
|
+
id: 'port.client-close-command.provider',
|
|
775
|
+
start() {
|
|
776
|
+
starts += 1;
|
|
777
|
+
return ok(undefined);
|
|
778
|
+
},
|
|
779
|
+
cancel() {
|
|
780
|
+
return ok(undefined);
|
|
781
|
+
},
|
|
782
|
+
async close() {
|
|
783
|
+
closeCalls += 1;
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
const runtime = createIntelligenceRuntime(provider);
|
|
787
|
+
const channel = new MessageChannel();
|
|
788
|
+
const rawClientPort = channel.port2;
|
|
789
|
+
const commandKinds: string[] = [];
|
|
790
|
+
let removeListenerCalls = 0;
|
|
791
|
+
let clientPortCloseCalls = 0;
|
|
792
|
+
const clientPort: IntelligenceMessagePort = {
|
|
793
|
+
postMessage(message) {
|
|
794
|
+
commandKinds.push(message.kind);
|
|
795
|
+
if (message.kind === 'intelligence-close') {
|
|
796
|
+
throw new Error('sentinel Worker client close command failure');
|
|
797
|
+
}
|
|
798
|
+
rawClientPort.postMessage(message);
|
|
799
|
+
},
|
|
800
|
+
addEventListener(type, listener) {
|
|
801
|
+
rawClientPort.addEventListener(type, listener as EventListener);
|
|
802
|
+
},
|
|
803
|
+
removeEventListener(type, listener) {
|
|
804
|
+
removeListenerCalls += 1;
|
|
805
|
+
rawClientPort.removeEventListener(type, listener as EventListener);
|
|
806
|
+
},
|
|
807
|
+
start() {
|
|
808
|
+
rawClientPort.start();
|
|
809
|
+
},
|
|
810
|
+
close() {
|
|
811
|
+
clientPortCloseCalls += 1;
|
|
812
|
+
rawClientPort.close();
|
|
813
|
+
},
|
|
814
|
+
};
|
|
815
|
+
const host = bindIntelligencePort(channel.port1 as unknown as IntelligenceMessagePort, runtime);
|
|
816
|
+
const client = createIntelligencePortClient(provider.id, clientPort, {
|
|
817
|
+
createActivityId: () => activityId('client-close-command-activity'),
|
|
818
|
+
createSessionId: () => 'client-close-command-session',
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
try {
|
|
822
|
+
const started = client.submit({ input: 'live close' });
|
|
823
|
+
expect(started.ok).toBe(true);
|
|
824
|
+
if (!started.ok) return;
|
|
825
|
+
for (let attempt = 0; attempt < 20 && starts === 0; attempt += 1) {
|
|
826
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
827
|
+
}
|
|
828
|
+
expect(starts).toBe(1);
|
|
829
|
+
|
|
830
|
+
client.poll();
|
|
831
|
+
const closeTask = client.close();
|
|
832
|
+
expect(client.close()).toBe(closeTask);
|
|
833
|
+
await expect(closeTask).resolves.toBeUndefined();
|
|
834
|
+
|
|
835
|
+
expect(commandKinds).toEqual([
|
|
836
|
+
'intelligence-submit',
|
|
837
|
+
'intelligence-poll',
|
|
838
|
+
'intelligence-close',
|
|
839
|
+
]);
|
|
840
|
+
expect(removeListenerCalls).toBe(1);
|
|
841
|
+
expect(clientPortCloseCalls).toBe(1);
|
|
842
|
+
expect(closeCalls).toBe(0);
|
|
843
|
+
|
|
844
|
+
const afterCloseSubmit = client.submit({ input: 'after close' });
|
|
845
|
+
expect(afterCloseSubmit.ok).toBe(false);
|
|
846
|
+
if (!afterCloseSubmit.ok) expect(afterCloseSubmit.error.code).toBe('intelligence-closed');
|
|
847
|
+
const afterCloseCancel = client.cancel(started.value.id);
|
|
848
|
+
expect(afterCloseCancel.ok).toBe(false);
|
|
849
|
+
if (!afterCloseCancel.ok) expect(afterCloseCancel.error.code).toBe('intelligence-closed');
|
|
850
|
+
expect(client.poll()).toEqual([]);
|
|
851
|
+
|
|
852
|
+
await expect(host.close()).resolves.toBeUndefined();
|
|
853
|
+
expect(closeCalls).toBe(1);
|
|
854
|
+
} finally {
|
|
855
|
+
rawClientPort.close();
|
|
856
|
+
await host.close().catch(() => undefined);
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
it('contains a synchronous Worker client submit command failure and clears local state', async () => {
|
|
861
|
+
const sinks = new Map<string, ActivitySink>();
|
|
862
|
+
const starts: string[] = [];
|
|
863
|
+
let closeCalls = 0;
|
|
864
|
+
const provider: IntelligenceProvider = {
|
|
865
|
+
id: 'port.client-submit-command.provider',
|
|
866
|
+
start(submission, sink) {
|
|
867
|
+
starts.push(submission.input);
|
|
868
|
+
sinks.set(String(submission.id), sink);
|
|
869
|
+
if (submission.input === 'live') sink.text('pending');
|
|
870
|
+
return ok(undefined);
|
|
871
|
+
},
|
|
872
|
+
cancel(id) {
|
|
873
|
+
sinks.get(String(id))?.cancelled();
|
|
874
|
+
return ok(undefined);
|
|
875
|
+
},
|
|
876
|
+
async close() {
|
|
877
|
+
closeCalls += 1;
|
|
878
|
+
},
|
|
879
|
+
};
|
|
880
|
+
const runtime = createIntelligenceRuntime(provider, {
|
|
881
|
+
limits: { maxConcurrentActivities: 1 },
|
|
882
|
+
});
|
|
883
|
+
const channel = new MessageChannel();
|
|
884
|
+
const rawClientPort = channel.port2;
|
|
885
|
+
const commandKinds: string[] = [];
|
|
886
|
+
let removeListenerCalls = 0;
|
|
887
|
+
let clientPortCloseCalls = 0;
|
|
888
|
+
const clientPort: IntelligenceMessagePort = {
|
|
889
|
+
postMessage(message) {
|
|
890
|
+
commandKinds.push(message.kind);
|
|
891
|
+
if (message.kind === 'intelligence-submit' && message.submission.input === 'submit-fault') {
|
|
892
|
+
throw new Error('sentinel Worker client submit command failure');
|
|
893
|
+
}
|
|
894
|
+
rawClientPort.postMessage(message);
|
|
895
|
+
},
|
|
896
|
+
addEventListener(type, listener) {
|
|
897
|
+
rawClientPort.addEventListener(type, listener as EventListener);
|
|
898
|
+
},
|
|
899
|
+
removeEventListener(type, listener) {
|
|
900
|
+
removeListenerCalls += 1;
|
|
901
|
+
rawClientPort.removeEventListener(type, listener as EventListener);
|
|
902
|
+
},
|
|
903
|
+
start() {
|
|
904
|
+
rawClientPort.start();
|
|
905
|
+
},
|
|
906
|
+
close() {
|
|
907
|
+
clientPortCloseCalls += 1;
|
|
908
|
+
rawClientPort.close();
|
|
909
|
+
},
|
|
910
|
+
};
|
|
911
|
+
const host = bindIntelligencePort(channel.port1 as unknown as IntelligenceMessagePort, runtime);
|
|
912
|
+
const client = createIntelligencePortClient(provider.id, clientPort, {
|
|
913
|
+
limits: { maxConcurrentActivities: 2 },
|
|
914
|
+
createActivityId: (() => {
|
|
915
|
+
let identity = 0;
|
|
916
|
+
return () => activityId(`client-submit-command-${++identity}`);
|
|
917
|
+
})(),
|
|
918
|
+
createSessionId: () => 'client-submit-command-session',
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
try {
|
|
922
|
+
const live = client.submit({ input: 'live' });
|
|
923
|
+
expect(live.ok).toBe(true);
|
|
924
|
+
const hostRejected = client.submit({ input: 'host-reject' });
|
|
925
|
+
expect(hostRejected.ok).toBe(true);
|
|
926
|
+
if (!live.ok || !hostRejected.ok) return;
|
|
927
|
+
|
|
928
|
+
let rejectionObserved = false;
|
|
929
|
+
const rejectionListener = (event: MessageEvent<unknown>): void => {
|
|
930
|
+
const value = event.data as { readonly kind?: unknown; readonly activityId?: unknown };
|
|
931
|
+
if (value.kind === 'intelligence-rejected' && value.activityId === hostRejected.value.id) {
|
|
932
|
+
rejectionObserved = true;
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
rawClientPort.addEventListener('message', rejectionListener);
|
|
936
|
+
rawClientPort.start();
|
|
937
|
+
for (let attempt = 0; attempt < 40 && !rejectionObserved; attempt += 1) {
|
|
938
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
939
|
+
}
|
|
940
|
+
rawClientPort.removeEventListener('message', rejectionListener);
|
|
941
|
+
expect(rejectionObserved).toBe(true);
|
|
942
|
+
|
|
943
|
+
client.poll();
|
|
944
|
+
for (
|
|
945
|
+
let attempt = 0;
|
|
946
|
+
attempt < 40 && !commandKinds.includes('intelligence-poll');
|
|
947
|
+
attempt += 1
|
|
948
|
+
) {
|
|
949
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
950
|
+
}
|
|
951
|
+
const failed = client.submit({ input: 'submit-fault' });
|
|
952
|
+
expect(failed.ok).toBe(false);
|
|
953
|
+
if (!failed.ok) expect(failed.error.code).toBe('intelligence-closed');
|
|
954
|
+
|
|
955
|
+
const commandCountAfterFailure = commandKinds.length;
|
|
956
|
+
expect(client.poll()).toEqual([]);
|
|
957
|
+
const afterFailure = client.submit({ input: 'after-failure' });
|
|
958
|
+
expect(afterFailure.ok).toBe(false);
|
|
959
|
+
if (!afterFailure.ok) expect(afterFailure.error.code).toBe('intelligence-closed');
|
|
960
|
+
const cancelAfterFailure = client.cancel(live.value.id);
|
|
961
|
+
expect(cancelAfterFailure.ok).toBe(false);
|
|
962
|
+
if (!cancelAfterFailure.ok) expect(cancelAfterFailure.error.code).toBe('intelligence-closed');
|
|
963
|
+
expect(commandKinds).toHaveLength(commandCountAfterFailure);
|
|
964
|
+
expect(starts).toEqual(['live']);
|
|
965
|
+
expect(removeListenerCalls).toBe(1);
|
|
966
|
+
expect(clientPortCloseCalls).toBe(1);
|
|
967
|
+
|
|
968
|
+
const closeTask = client.close();
|
|
969
|
+
expect(client.close()).toBe(closeTask);
|
|
970
|
+
await expect(closeTask).resolves.toBeUndefined();
|
|
971
|
+
await expect(host.close()).resolves.toBeUndefined();
|
|
972
|
+
expect(closeCalls).toBe(1);
|
|
973
|
+
sinks.get(String(live.value.id))?.complete('late');
|
|
974
|
+
expect(runtime.poll()).toEqual([]);
|
|
975
|
+
} finally {
|
|
976
|
+
rawClientPort.close();
|
|
977
|
+
await host.close().catch(() => undefined);
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it('contains a synchronous Worker client cancel command failure and releases local transport', async () => {
|
|
982
|
+
const sinks = new Map<string, ActivitySink>();
|
|
983
|
+
let starts = 0;
|
|
984
|
+
let cancelCalls = 0;
|
|
985
|
+
let closeCalls = 0;
|
|
986
|
+
const provider: IntelligenceProvider = {
|
|
987
|
+
id: 'port.client-cancel-command.provider',
|
|
988
|
+
start(submission, sink) {
|
|
989
|
+
starts += 1;
|
|
990
|
+
sinks.set(String(submission.id), sink);
|
|
991
|
+
sink.text('staged-before-cancel-fault');
|
|
992
|
+
return ok(undefined);
|
|
993
|
+
},
|
|
994
|
+
cancel(id) {
|
|
995
|
+
cancelCalls += 1;
|
|
996
|
+
sinks.get(String(id))?.cancelled();
|
|
997
|
+
return ok(undefined);
|
|
998
|
+
},
|
|
999
|
+
async close() {
|
|
1000
|
+
closeCalls += 1;
|
|
1001
|
+
},
|
|
1002
|
+
};
|
|
1003
|
+
const runtime = createIntelligenceRuntime(provider, {
|
|
1004
|
+
limits: { maxConcurrentActivities: 1, maxPollEvents: 1 },
|
|
1005
|
+
});
|
|
1006
|
+
const channel = new MessageChannel();
|
|
1007
|
+
const rawHostPort = channel.port1;
|
|
1008
|
+
const rawClientPort = channel.port2;
|
|
1009
|
+
const clientCommands: string[] = [];
|
|
1010
|
+
const hostCommands: string[] = [];
|
|
1011
|
+
let stagedEventObserved = false;
|
|
1012
|
+
let removeListenerCalls = 0;
|
|
1013
|
+
let clientPortCloseCalls = 0;
|
|
1014
|
+
rawHostPort.addEventListener('message', (event: MessageEvent<unknown>) => {
|
|
1015
|
+
const message = event.data as { readonly kind?: unknown };
|
|
1016
|
+
if (typeof message.kind === 'string') hostCommands.push(message.kind);
|
|
1017
|
+
});
|
|
1018
|
+
rawHostPort.start();
|
|
1019
|
+
rawClientPort.addEventListener('message', (event: MessageEvent<unknown>) => {
|
|
1020
|
+
const message = event.data as {
|
|
1021
|
+
readonly kind?: unknown;
|
|
1022
|
+
readonly events?: readonly unknown[];
|
|
1023
|
+
};
|
|
1024
|
+
if (message.kind === 'intelligence-events' && (message.events?.length ?? 0) > 0) {
|
|
1025
|
+
stagedEventObserved = true;
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
rawClientPort.start();
|
|
1029
|
+
const clientPort: IntelligenceMessagePort = {
|
|
1030
|
+
postMessage(message) {
|
|
1031
|
+
clientCommands.push(message.kind);
|
|
1032
|
+
if (message.kind === 'intelligence-cancel') {
|
|
1033
|
+
throw new Error('sentinel Worker client cancel command failure');
|
|
1034
|
+
}
|
|
1035
|
+
rawClientPort.postMessage(message);
|
|
1036
|
+
},
|
|
1037
|
+
addEventListener(type, listener) {
|
|
1038
|
+
rawClientPort.addEventListener(type, listener as EventListener);
|
|
1039
|
+
},
|
|
1040
|
+
removeEventListener(type, listener) {
|
|
1041
|
+
removeListenerCalls += 1;
|
|
1042
|
+
rawClientPort.removeEventListener(type, listener as EventListener);
|
|
1043
|
+
},
|
|
1044
|
+
start() {
|
|
1045
|
+
rawClientPort.start();
|
|
1046
|
+
},
|
|
1047
|
+
close() {
|
|
1048
|
+
clientPortCloseCalls += 1;
|
|
1049
|
+
rawClientPort.close();
|
|
1050
|
+
},
|
|
1051
|
+
};
|
|
1052
|
+
const host = bindIntelligencePort(rawHostPort as unknown as IntelligenceMessagePort, runtime);
|
|
1053
|
+
let activityIdentityCalls = 0;
|
|
1054
|
+
let sessionIdentityCalls = 0;
|
|
1055
|
+
const client = createIntelligencePortClient(provider.id, clientPort, {
|
|
1056
|
+
limits: { maxConcurrentActivities: 1, maxPollEvents: 1 },
|
|
1057
|
+
createActivityId: () => activityId(`client-cancel-command-${++activityIdentityCalls}`),
|
|
1058
|
+
createSessionId: () => `client-cancel-command-session-${++sessionIdentityCalls}`,
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
try {
|
|
1062
|
+
const started = client.submit({ input: 'live-cancel' });
|
|
1063
|
+
expect(started.ok).toBe(true);
|
|
1064
|
+
if (!started.ok) return;
|
|
1065
|
+
for (let attempt = 0; attempt < 40 && starts === 0; attempt += 1) {
|
|
1066
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
1067
|
+
}
|
|
1068
|
+
expect(starts).toBe(1);
|
|
1069
|
+
|
|
1070
|
+
expect(client.poll()).toEqual([]);
|
|
1071
|
+
for (let attempt = 0; attempt < 40 && !stagedEventObserved; attempt += 1) {
|
|
1072
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
1073
|
+
}
|
|
1074
|
+
expect(stagedEventObserved).toBe(true);
|
|
1075
|
+
|
|
1076
|
+
let cancellation: ReturnType<typeof client.cancel> | undefined;
|
|
1077
|
+
let thrown: unknown;
|
|
1078
|
+
try {
|
|
1079
|
+
cancellation = client.cancel(started.value.id);
|
|
1080
|
+
} catch (cause) {
|
|
1081
|
+
thrown = cause;
|
|
1082
|
+
}
|
|
1083
|
+
expect(thrown).toBeUndefined();
|
|
1084
|
+
expect(cancellation?.ok).toBe(false);
|
|
1085
|
+
if (cancellation !== undefined && !cancellation.ok) {
|
|
1086
|
+
expect(cancellation.error.code).toBe('intelligence-closed');
|
|
1087
|
+
}
|
|
1088
|
+
expect(clientCommands).toEqual([
|
|
1089
|
+
'intelligence-submit',
|
|
1090
|
+
'intelligence-poll',
|
|
1091
|
+
'intelligence-cancel',
|
|
1092
|
+
]);
|
|
1093
|
+
expect(hostCommands).toEqual(['intelligence-submit', 'intelligence-poll']);
|
|
1094
|
+
expect(cancelCalls).toBe(0);
|
|
1095
|
+
expect(removeListenerCalls).toBe(1);
|
|
1096
|
+
expect(clientPortCloseCalls).toBe(1);
|
|
1097
|
+
|
|
1098
|
+
const commandCountAfterFault = clientCommands.length;
|
|
1099
|
+
expect(client.submit({ input: 'after-cancel-fault' }).ok).toBe(false);
|
|
1100
|
+
expect(client.cancel(started.value.id).ok).toBe(false);
|
|
1101
|
+
expect(client.poll()).toEqual([]);
|
|
1102
|
+
expect(clientCommands).toHaveLength(commandCountAfterFault);
|
|
1103
|
+
expect(activityIdentityCalls).toBe(1);
|
|
1104
|
+
expect(sessionIdentityCalls).toBe(1);
|
|
1105
|
+
|
|
1106
|
+
await expect(host.close()).resolves.toBeUndefined();
|
|
1107
|
+
expect(closeCalls).toBe(1);
|
|
1108
|
+
sinks.get(String(started.value.id))?.text('late');
|
|
1109
|
+
sinks.get(String(started.value.id))?.complete('late');
|
|
1110
|
+
expect(client.poll()).toEqual([]);
|
|
1111
|
+
} finally {
|
|
1112
|
+
rawClientPort.close();
|
|
1113
|
+
await host.close().catch(() => undefined);
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
it('contains a synchronous Worker client poll command failure and discards staged events', async () => {
|
|
1118
|
+
const sinks = new Map<string, ActivitySink>();
|
|
1119
|
+
const starts: string[] = [];
|
|
1120
|
+
let closeCalls = 0;
|
|
1121
|
+
const provider: IntelligenceProvider = {
|
|
1122
|
+
id: 'port.client-poll-command.provider',
|
|
1123
|
+
start(submission, sink) {
|
|
1124
|
+
starts.push(submission.input);
|
|
1125
|
+
sinks.set(String(submission.id), sink);
|
|
1126
|
+
sink.text('staged-before-poll-fault');
|
|
1127
|
+
return ok(undefined);
|
|
1128
|
+
},
|
|
1129
|
+
cancel(id) {
|
|
1130
|
+
sinks.get(String(id))?.cancelled();
|
|
1131
|
+
return ok(undefined);
|
|
1132
|
+
},
|
|
1133
|
+
async close() {
|
|
1134
|
+
closeCalls += 1;
|
|
1135
|
+
},
|
|
1136
|
+
};
|
|
1137
|
+
const runtime = createIntelligenceRuntime(provider, {
|
|
1138
|
+
limits: { maxConcurrentActivities: 1, maxPollEvents: 1 },
|
|
1139
|
+
});
|
|
1140
|
+
const channel = new MessageChannel();
|
|
1141
|
+
const rawHostPort = channel.port1;
|
|
1142
|
+
const rawClientPort = channel.port2;
|
|
1143
|
+
const commandKinds: string[] = [];
|
|
1144
|
+
let pollRequests = 0;
|
|
1145
|
+
let normalPollResponses = 0;
|
|
1146
|
+
let stagedEventObserved = false;
|
|
1147
|
+
let throwNextPoll = false;
|
|
1148
|
+
let pollFaultThrows = 0;
|
|
1149
|
+
let removeListenerCalls = 0;
|
|
1150
|
+
let clientPortCloseCalls = 0;
|
|
1151
|
+
rawHostPort.addEventListener('message', (event: MessageEvent<unknown>) => {
|
|
1152
|
+
const message = event.data as { readonly kind?: unknown };
|
|
1153
|
+
if (message.kind === 'intelligence-poll') pollRequests += 1;
|
|
1154
|
+
});
|
|
1155
|
+
rawHostPort.start();
|
|
1156
|
+
rawClientPort.addEventListener('message', (event: MessageEvent<unknown>) => {
|
|
1157
|
+
const message = event.data as {
|
|
1158
|
+
readonly kind?: unknown;
|
|
1159
|
+
readonly events?: readonly unknown[];
|
|
1160
|
+
};
|
|
1161
|
+
if (message.kind === 'intelligence-events') {
|
|
1162
|
+
normalPollResponses += 1;
|
|
1163
|
+
if ((message.events?.length ?? 0) > 0) {
|
|
1164
|
+
stagedEventObserved = true;
|
|
1165
|
+
throwNextPoll = true;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
rawClientPort.start();
|
|
1170
|
+
const clientPort: IntelligenceMessagePort = {
|
|
1171
|
+
postMessage(message) {
|
|
1172
|
+
commandKinds.push(message.kind);
|
|
1173
|
+
if (message.kind === 'intelligence-poll' && throwNextPoll) {
|
|
1174
|
+
throwNextPoll = false;
|
|
1175
|
+
pollFaultThrows += 1;
|
|
1176
|
+
throw new Error('sentinel Worker client poll command failure');
|
|
1177
|
+
}
|
|
1178
|
+
rawClientPort.postMessage(message);
|
|
1179
|
+
},
|
|
1180
|
+
addEventListener(type, listener) {
|
|
1181
|
+
rawClientPort.addEventListener(type, listener as EventListener);
|
|
1182
|
+
},
|
|
1183
|
+
removeEventListener(type, listener) {
|
|
1184
|
+
removeListenerCalls += 1;
|
|
1185
|
+
rawClientPort.removeEventListener(type, listener as EventListener);
|
|
1186
|
+
},
|
|
1187
|
+
start() {
|
|
1188
|
+
rawClientPort.start();
|
|
1189
|
+
},
|
|
1190
|
+
close() {
|
|
1191
|
+
clientPortCloseCalls += 1;
|
|
1192
|
+
rawClientPort.close();
|
|
1193
|
+
},
|
|
1194
|
+
};
|
|
1195
|
+
const host = bindIntelligencePort(rawHostPort as unknown as IntelligenceMessagePort, runtime);
|
|
1196
|
+
const client = createIntelligencePortClient(provider.id, clientPort, {
|
|
1197
|
+
limits: { maxConcurrentActivities: 1, maxPollEvents: 1 },
|
|
1198
|
+
createActivityId: (() => {
|
|
1199
|
+
let identity = 0;
|
|
1200
|
+
return () => activityId(`client-poll-command-${++identity}`);
|
|
1201
|
+
})(),
|
|
1202
|
+
createSessionId: () => 'client-poll-command-session',
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
try {
|
|
1206
|
+
const started = client.submit({ input: 'live' });
|
|
1207
|
+
expect(started.ok).toBe(true);
|
|
1208
|
+
if (!started.ok) return;
|
|
1209
|
+
for (let attempt = 0; attempt < 40 && starts.length === 0; attempt += 1) {
|
|
1210
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
1211
|
+
}
|
|
1212
|
+
expect(starts).toEqual(['live']);
|
|
1213
|
+
|
|
1214
|
+
expect(client.poll()).toEqual([]);
|
|
1215
|
+
for (let attempt = 0; attempt < 40 && !stagedEventObserved; attempt += 1) {
|
|
1216
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
1217
|
+
}
|
|
1218
|
+
expect(normalPollResponses).toBe(1);
|
|
1219
|
+
expect(stagedEventObserved).toBe(true);
|
|
1220
|
+
expect(pollRequests).toBe(1);
|
|
1221
|
+
|
|
1222
|
+
const failedPoll = client.poll();
|
|
1223
|
+
expect(failedPoll).toEqual([]);
|
|
1224
|
+
expect(pollFaultThrows).toBe(1);
|
|
1225
|
+
expect(commandKinds).toEqual([
|
|
1226
|
+
'intelligence-submit',
|
|
1227
|
+
'intelligence-poll',
|
|
1228
|
+
'intelligence-poll',
|
|
1229
|
+
]);
|
|
1230
|
+
expect(pollRequests).toBe(1);
|
|
1231
|
+
|
|
1232
|
+
expect(client.poll()).toEqual([]);
|
|
1233
|
+
const afterFailure = client.submit({ input: 'after-failure' });
|
|
1234
|
+
expect(afterFailure.ok).toBe(false);
|
|
1235
|
+
if (!afterFailure.ok) expect(afterFailure.error.code).toBe('intelligence-closed');
|
|
1236
|
+
const cancelAfterFailure = client.cancel(started.value.id);
|
|
1237
|
+
expect(cancelAfterFailure.ok).toBe(false);
|
|
1238
|
+
if (!cancelAfterFailure.ok) expect(cancelAfterFailure.error.code).toBe('intelligence-closed');
|
|
1239
|
+
expect(commandKinds).toHaveLength(3);
|
|
1240
|
+
expect(starts).toEqual(['live']);
|
|
1241
|
+
expect(removeListenerCalls).toBe(1);
|
|
1242
|
+
expect(clientPortCloseCalls).toBe(1);
|
|
1243
|
+
|
|
1244
|
+
const closeTask = client.close();
|
|
1245
|
+
expect(client.close()).toBe(closeTask);
|
|
1246
|
+
await expect(closeTask).resolves.toBeUndefined();
|
|
1247
|
+
await expect(host.close()).resolves.toBeUndefined();
|
|
1248
|
+
expect(closeCalls).toBe(1);
|
|
1249
|
+
sinks.get(String(started.value.id))?.complete('late');
|
|
1250
|
+
expect(runtime.poll()).toEqual([]);
|
|
1251
|
+
} finally {
|
|
1252
|
+
rawClientPort.close();
|
|
1253
|
+
await host.close().catch(() => undefined);
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
it('contains a synchronous Host close notification failure and releases for a fresh binding', async () => {
|
|
1258
|
+
let sink: ActivitySink | undefined;
|
|
1259
|
+
let startCalls = 0;
|
|
1260
|
+
let closeCalls = 0;
|
|
1261
|
+
let portCloseCalls = 0;
|
|
1262
|
+
const provider: IntelligenceProvider = {
|
|
1263
|
+
id: 'port.close.notification-failure.provider',
|
|
1264
|
+
start(_submission, nextSink) {
|
|
1265
|
+
startCalls += 1;
|
|
1266
|
+
sink = nextSink;
|
|
1267
|
+
return ok(undefined);
|
|
1268
|
+
},
|
|
1269
|
+
cancel() {
|
|
1270
|
+
return ok(undefined);
|
|
1271
|
+
},
|
|
1272
|
+
async close() {
|
|
1273
|
+
closeCalls += 1;
|
|
1274
|
+
},
|
|
1275
|
+
};
|
|
1276
|
+
const runtime = createIntelligenceRuntime(provider);
|
|
1277
|
+
const channel = new MessageChannel();
|
|
1278
|
+
const rawHostPort = channel.port1;
|
|
1279
|
+
const rawClientPort = channel.port2;
|
|
1280
|
+
const hostPort: IntelligenceMessagePort = {
|
|
1281
|
+
postMessage(message) {
|
|
1282
|
+
if (message.kind === 'intelligence-closed') {
|
|
1283
|
+
throw new Error('sentinel Host close notification failure');
|
|
1284
|
+
}
|
|
1285
|
+
rawHostPort.postMessage(message);
|
|
1286
|
+
},
|
|
1287
|
+
addEventListener(type, listener) {
|
|
1288
|
+
rawHostPort.addEventListener(type, listener as EventListener);
|
|
1289
|
+
},
|
|
1290
|
+
removeEventListener(type, listener) {
|
|
1291
|
+
rawHostPort.removeEventListener(type, listener as EventListener);
|
|
1292
|
+
},
|
|
1293
|
+
start() {
|
|
1294
|
+
rawHostPort.start();
|
|
1295
|
+
},
|
|
1296
|
+
close() {
|
|
1297
|
+
portCloseCalls += 1;
|
|
1298
|
+
rawHostPort.close();
|
|
1299
|
+
},
|
|
1300
|
+
};
|
|
1301
|
+
const host = bindIntelligencePort(hostPort, runtime);
|
|
1302
|
+
const client = createIntelligencePortClient(
|
|
1303
|
+
provider.id,
|
|
1304
|
+
rawClientPort as unknown as IntelligenceMessagePort,
|
|
1305
|
+
);
|
|
1306
|
+
|
|
1307
|
+
try {
|
|
1308
|
+
const started = client.submit({ input: 'live close' });
|
|
1309
|
+
expect(started.ok).toBe(true);
|
|
1310
|
+
client.poll();
|
|
1311
|
+
for (let attempt = 0; attempt < 20 && startCalls === 0; attempt += 1) {
|
|
1312
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
1313
|
+
}
|
|
1314
|
+
expect(startCalls).toBe(1);
|
|
1315
|
+
|
|
1316
|
+
await expect(host.close()).resolves.toBeUndefined();
|
|
1317
|
+
expect(closeCalls).toBe(1);
|
|
1318
|
+
expect(portCloseCalls).toBe(1);
|
|
1319
|
+
|
|
1320
|
+
sink?.text('late');
|
|
1321
|
+
sink?.complete('late');
|
|
1322
|
+
expect(runtime.poll()).toEqual([]);
|
|
1323
|
+
|
|
1324
|
+
const freshChannel = new MessageChannel();
|
|
1325
|
+
let freshCloseCalls = 0;
|
|
1326
|
+
const freshProvider: IntelligenceProvider = {
|
|
1327
|
+
id: 'port.close.notification-failure.recovery-provider',
|
|
1328
|
+
start(_submission, nextSink) {
|
|
1329
|
+
nextSink.complete('fresh-run');
|
|
1330
|
+
return ok(undefined);
|
|
1331
|
+
},
|
|
1332
|
+
cancel() {
|
|
1333
|
+
return ok(undefined);
|
|
1334
|
+
},
|
|
1335
|
+
async close() {
|
|
1336
|
+
freshCloseCalls += 1;
|
|
1337
|
+
},
|
|
1338
|
+
};
|
|
1339
|
+
const freshHost = bindIntelligencePort(
|
|
1340
|
+
freshChannel.port1 as unknown as IntelligenceMessagePort,
|
|
1341
|
+
createIntelligenceRuntime(freshProvider),
|
|
1342
|
+
);
|
|
1343
|
+
const freshClient = createIntelligencePortClient(
|
|
1344
|
+
freshProvider.id,
|
|
1345
|
+
freshChannel.port2 as unknown as IntelligenceMessagePort,
|
|
1346
|
+
);
|
|
1347
|
+
try {
|
|
1348
|
+
expect(freshClient.submit({ input: 'fresh binding' }).ok).toBe(true);
|
|
1349
|
+
let freshEvents: readonly ReturnType<typeof freshClient.poll>[number][] = [];
|
|
1350
|
+
for (let attempt = 0; attempt < 20 && freshEvents.length === 0; attempt += 1) {
|
|
1351
|
+
freshEvents = freshClient.poll();
|
|
1352
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
1353
|
+
}
|
|
1354
|
+
expect(freshEvents).toEqual([
|
|
1355
|
+
expect.objectContaining({ type: 'completed', output: 'fresh-run' }),
|
|
1356
|
+
]);
|
|
1357
|
+
await freshClient.close();
|
|
1358
|
+
await freshHost.close();
|
|
1359
|
+
expect(freshCloseCalls).toBe(1);
|
|
1360
|
+
} finally {
|
|
1361
|
+
await freshHost.close().catch(() => undefined);
|
|
1362
|
+
freshChannel.port2.close();
|
|
1363
|
+
}
|
|
1364
|
+
} finally {
|
|
1365
|
+
rawClientPort.close();
|
|
1366
|
+
await host.close().catch(() => undefined);
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
it('contains a synchronous Host poll response failure after the runtime has drained events', async () => {
|
|
1371
|
+
let sink: ActivitySink | undefined;
|
|
1372
|
+
let closeCalls = 0;
|
|
1373
|
+
let removeListenerCalls = 0;
|
|
1374
|
+
let portCloseCalls = 0;
|
|
1375
|
+
let hostListener:
|
|
1376
|
+
| ((event: MessageEvent<IntelligenceHostCommand | IntelligenceRealmMessage>) => void)
|
|
1377
|
+
| undefined;
|
|
1378
|
+
const posted: string[] = [];
|
|
1379
|
+
const provider: IntelligenceProvider = {
|
|
1380
|
+
id: 'port.host-poll-response-failure.provider',
|
|
1381
|
+
start(_submission, nextSink) {
|
|
1382
|
+
sink = nextSink;
|
|
1383
|
+
return ok(undefined);
|
|
1384
|
+
},
|
|
1385
|
+
cancel() {
|
|
1386
|
+
return ok(undefined);
|
|
1387
|
+
},
|
|
1388
|
+
async close() {
|
|
1389
|
+
closeCalls += 1;
|
|
1390
|
+
},
|
|
1391
|
+
};
|
|
1392
|
+
const runtime = createIntelligenceRuntime(provider, { limits: { maxPollEvents: 1 } });
|
|
1393
|
+
const hostPort: IntelligenceMessagePort = {
|
|
1394
|
+
postMessage(message) {
|
|
1395
|
+
posted.push(message.kind);
|
|
1396
|
+
if (message.kind === 'intelligence-events') {
|
|
1397
|
+
throw new Error('sentinel Host poll response publication failure');
|
|
1398
|
+
}
|
|
1399
|
+
},
|
|
1400
|
+
addEventListener(_type, listener) {
|
|
1401
|
+
hostListener = listener;
|
|
1402
|
+
},
|
|
1403
|
+
removeEventListener(_type, listener) {
|
|
1404
|
+
removeListenerCalls += Number(listener === hostListener);
|
|
1405
|
+
hostListener = undefined;
|
|
1406
|
+
},
|
|
1407
|
+
start() {},
|
|
1408
|
+
close() {
|
|
1409
|
+
portCloseCalls += 1;
|
|
1410
|
+
},
|
|
1411
|
+
};
|
|
1412
|
+
const host = bindIntelligencePort(hostPort, runtime);
|
|
1413
|
+
const activity = {
|
|
1414
|
+
id: activityId('host-poll-response-failure-activity'),
|
|
1415
|
+
session: {
|
|
1416
|
+
providerId: provider.id,
|
|
1417
|
+
id: 'host-poll-response-failure-session',
|
|
1418
|
+
},
|
|
1419
|
+
input: 'live poll response failure',
|
|
1420
|
+
} as const;
|
|
1421
|
+
|
|
1422
|
+
try {
|
|
1423
|
+
expect(runtime.accept(activity).ok).toBe(true);
|
|
1424
|
+
sink?.text('staged-before-host-response-fault');
|
|
1425
|
+
expect(hostListener).toBeDefined();
|
|
1426
|
+
expect(() =>
|
|
1427
|
+
hostListener?.({
|
|
1428
|
+
data: { kind: 'intelligence-poll', maxEvents: 1 },
|
|
1429
|
+
} as MessageEvent<IntelligenceHostCommand>),
|
|
1430
|
+
).not.toThrow();
|
|
1431
|
+
|
|
1432
|
+
const closeTask = host.close();
|
|
1433
|
+
expect(host.close()).toBe(closeTask);
|
|
1434
|
+
await expect(closeTask).resolves.toBeUndefined();
|
|
1435
|
+
expect(posted).toEqual(['intelligence-events', 'intelligence-closed']);
|
|
1436
|
+
expect(closeCalls).toBe(1);
|
|
1437
|
+
expect(removeListenerCalls).toBe(1);
|
|
1438
|
+
expect(portCloseCalls).toBe(1);
|
|
1439
|
+
expect(runtime.poll()).toEqual([]);
|
|
1440
|
+
} finally {
|
|
1441
|
+
await host.close().catch(() => undefined);
|
|
1442
|
+
}
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
it('propagates direct Host binding close to a live realm client', async () => {
|
|
1446
|
+
let sink: ActivitySink | undefined;
|
|
1447
|
+
let starts = 0;
|
|
1448
|
+
let closeCalls = 0;
|
|
1449
|
+
const provider: IntelligenceProvider = {
|
|
1450
|
+
id: 'port.host-close.provider',
|
|
1451
|
+
start(_submission, nextSink) {
|
|
1452
|
+
starts += 1;
|
|
1453
|
+
sink = nextSink;
|
|
1454
|
+
return ok(undefined);
|
|
1455
|
+
},
|
|
1456
|
+
cancel() {
|
|
1457
|
+
return ok(undefined);
|
|
1458
|
+
},
|
|
1459
|
+
async close() {
|
|
1460
|
+
closeCalls += 1;
|
|
1461
|
+
},
|
|
1462
|
+
};
|
|
1463
|
+
const runtime = createIntelligenceRuntime(provider, {
|
|
1464
|
+
limits: { maxConcurrentActivities: 1 },
|
|
1465
|
+
});
|
|
1466
|
+
const channel = new MessageChannel();
|
|
1467
|
+
const rawClientPort = channel.port2;
|
|
1468
|
+
let closedMessageObserved = false;
|
|
1469
|
+
rawClientPort.addEventListener('message', (event: MessageEvent<unknown>) => {
|
|
1470
|
+
const message = event.data;
|
|
1471
|
+
if (typeof message === 'object' && message !== null && 'kind' in message) {
|
|
1472
|
+
if ((message as { readonly kind?: unknown }).kind === 'intelligence-closed') {
|
|
1473
|
+
closedMessageObserved = true;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
});
|
|
1477
|
+
rawClientPort.start();
|
|
1478
|
+
const clientCommands: string[] = [];
|
|
1479
|
+
const clientPort: IntelligenceMessagePort = {
|
|
1480
|
+
postMessage(message) {
|
|
1481
|
+
if (
|
|
1482
|
+
message.kind === 'intelligence-submit' ||
|
|
1483
|
+
message.kind === 'intelligence-poll' ||
|
|
1484
|
+
message.kind === 'intelligence-cancel' ||
|
|
1485
|
+
message.kind === 'intelligence-close'
|
|
1486
|
+
) {
|
|
1487
|
+
clientCommands.push(message.kind);
|
|
1488
|
+
}
|
|
1489
|
+
rawClientPort.postMessage(message);
|
|
1490
|
+
},
|
|
1491
|
+
addEventListener(type, listener) {
|
|
1492
|
+
rawClientPort.addEventListener(type, listener as EventListener);
|
|
1493
|
+
},
|
|
1494
|
+
removeEventListener(type, listener) {
|
|
1495
|
+
rawClientPort.removeEventListener(type, listener as EventListener);
|
|
1496
|
+
},
|
|
1497
|
+
start() {
|
|
1498
|
+
rawClientPort.start();
|
|
1499
|
+
},
|
|
1500
|
+
close() {
|
|
1501
|
+
rawClientPort.close();
|
|
1502
|
+
},
|
|
1503
|
+
};
|
|
1504
|
+
const host = bindIntelligencePort(channel.port1 as unknown as IntelligenceMessagePort, runtime);
|
|
1505
|
+
let activityIdentityCalls = 0;
|
|
1506
|
+
let sessionIdentityCalls = 0;
|
|
1507
|
+
const client = createIntelligencePortClient(provider.id, clientPort, {
|
|
1508
|
+
limits: { maxConcurrentActivities: 1 },
|
|
1509
|
+
createActivityId: () => {
|
|
1510
|
+
activityIdentityCalls += 1;
|
|
1511
|
+
return activityId(`host-close-${activityIdentityCalls}`);
|
|
1512
|
+
},
|
|
1513
|
+
createSessionId: () => {
|
|
1514
|
+
sessionIdentityCalls += 1;
|
|
1515
|
+
return `host-close-session-${sessionIdentityCalls}`;
|
|
1516
|
+
},
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
try {
|
|
1520
|
+
const started = client.submit({ input: 'live' });
|
|
1521
|
+
expect(started.ok).toBe(true);
|
|
1522
|
+
if (!started.ok) return;
|
|
1523
|
+
for (let attempt = 0; attempt < 20 && starts === 0; attempt += 1) {
|
|
1524
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
1525
|
+
}
|
|
1526
|
+
expect(starts).toBe(1);
|
|
1527
|
+
client.poll();
|
|
1528
|
+
|
|
1529
|
+
await host.close();
|
|
1530
|
+
for (let attempt = 0; attempt < 20 && !closedMessageObserved; attempt += 1) {
|
|
1531
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
1532
|
+
}
|
|
1533
|
+
expect(closedMessageObserved).toBe(true);
|
|
1534
|
+
|
|
1535
|
+
const afterClose = client.submit({ input: 'after-close' });
|
|
1536
|
+
expect(afterClose.ok).toBe(false);
|
|
1537
|
+
if (!afterClose.ok) expect(afterClose.error.code).toBe('intelligence-closed');
|
|
1538
|
+
const cancelled = client.cancel(started.value.id);
|
|
1539
|
+
expect(cancelled.ok).toBe(false);
|
|
1540
|
+
if (!cancelled.ok) expect(cancelled.error.code).toBe('intelligence-closed');
|
|
1541
|
+
expect(client.poll()).toEqual([]);
|
|
1542
|
+
expect(activityIdentityCalls).toBe(1);
|
|
1543
|
+
expect(sessionIdentityCalls).toBe(1);
|
|
1544
|
+
expect(starts).toBe(1);
|
|
1545
|
+
expect(closeCalls).toBe(1);
|
|
1546
|
+
expect(clientCommands).toEqual(['intelligence-submit', 'intelligence-poll']);
|
|
1547
|
+
|
|
1548
|
+
const closeSettled = await Promise.race([
|
|
1549
|
+
client.close().then(
|
|
1550
|
+
() => true,
|
|
1551
|
+
() => false,
|
|
1552
|
+
),
|
|
1553
|
+
new Promise<false>((resolve) => setTimeout(() => resolve(false), 250)),
|
|
1554
|
+
]);
|
|
1555
|
+
expect(closeSettled).toBe(true);
|
|
1556
|
+
|
|
1557
|
+
sink?.text('late');
|
|
1558
|
+
sink?.complete('late');
|
|
1559
|
+
expect(client.poll()).toEqual([]);
|
|
1560
|
+
} finally {
|
|
1561
|
+
await host.close();
|
|
1562
|
+
clientPort.close();
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
130
1565
|
});
|