@modelprofile.com/flexharness 11.1.0 → 11.2.0

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.
Files changed (33) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/classes.codesandbox.d.ts +58 -7
  3. package/dist_ts/classes.codesandbox.js +267 -34
  4. package/dist_ts/classes.codesandbox.worker.d.ts +6 -1
  5. package/dist_ts/classes.codesandbox.worker.js +34 -8
  6. package/dist_ts/classes.flexharness.callbacks.d.ts +6 -0
  7. package/dist_ts/classes.flexharness.callbacks.js +24 -4
  8. package/dist_ts/classes.flexharness.core.d.ts +8 -0
  9. package/dist_ts/classes.flexharness.core.js +29 -1
  10. package/dist_ts/classes.flexharness.lifecycle.js +7 -1
  11. package/dist_ts/classes.flexharness.runs.js +9 -7
  12. package/dist_ts/classes.flexharness.tooldispatch.js +2 -2
  13. package/dist_ts/interfaces.d.ts +9 -1
  14. package/dist_ts/interfaces.flexharness.d.ts +5 -3
  15. package/dist_ts/plugins.d.ts +3 -1
  16. package/dist_ts/plugins.js +2 -2
  17. package/dist_ts_chat_harness/classes.transcript.d.ts +1 -0
  18. package/dist_ts_chat_harness/classes.transcript.js +16 -1
  19. package/package.json +3 -3
  20. package/readme.md +5 -2
  21. package/ts/00_commitinfo_data.ts +1 -1
  22. package/ts/classes.codesandbox.ts +303 -34
  23. package/ts/classes.codesandbox.worker.ts +38 -8
  24. package/ts/classes.flexharness.callbacks.ts +22 -3
  25. package/ts/classes.flexharness.core.ts +31 -0
  26. package/ts/classes.flexharness.lifecycle.ts +5 -0
  27. package/ts/classes.flexharness.runs.ts +8 -5
  28. package/ts/classes.flexharness.tooldispatch.ts +1 -1
  29. package/ts/interfaces.flexharness.ts +5 -3
  30. package/ts/interfaces.ts +10 -1
  31. package/ts/plugins.ts +3 -1
  32. package/ts/readme.md +5 -2
  33. package/ts_chat_harness/classes.transcript.ts +19 -0
@@ -1,5 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
- import { FlexHarnessValidationError } from './errors.js';
2
+ import { FlexHarnessClosedError, FlexHarnessValidationError } from './errors.js';
3
3
  import type { IFlexCodeModeLimits, IFlexCodeRunFailure, TJsonValue } from './interfaces.js';
4
4
  import type { TCodeWorkerPhase, TCodeWorkerReport, TCodeWorkerRequest } from './classes.codesandbox.worker.js';
5
5
  import { maxTransferMetadataBytes, truncateUtf8 } from './utils.transfer.js';
@@ -27,11 +27,44 @@ export type TFlexCodeRunResult =
27
27
  | { ok: true; value: TJsonValue; calls: number; logs?: string }
28
28
  | { ok: false; failure: IFlexCodeRunFailure };
29
29
 
30
+ /**
31
+ * When a sandbox replaces its workers. A worker runs programs one after another, each in a QuickJS
32
+ * runtime of its own that it frees after the program, and is ended after a limit stopped a program,
33
+ * when it fails, after `maxPrograms`, once its WebAssembly memory grew past `maxMemoryBytes`, or after
34
+ * waiting `idleMs` for a program.
35
+ */
36
+ export interface IFlexCodeSandboxPolicy {
37
+ /** Programs one worker runs before it is replaced. */
38
+ maxPrograms: number;
39
+ /** The WebAssembly memory a worker may have grown to and still run another program; it never shrinks. */
40
+ maxMemoryBytes: number;
41
+ /** How long a worker waits for its next program before it ends. */
42
+ idleMs: number;
43
+ /** Workers kept waiting for programs, across scopes; the one that waited longest goes first. */
44
+ maxWaiting: number;
45
+ }
46
+
47
+ export const FLEX_CODE_SANDBOX_DEFAULT_POLICY: Readonly<IFlexCodeSandboxPolicy> = Object.freeze({
48
+ maxPrograms: 100,
49
+ maxMemoryBytes: 64 * 1024 * 1024,
50
+ idleMs: 60_000,
51
+ maxWaiting: 4,
52
+ });
53
+
30
54
  type TStop =
31
55
  | { kind: 'limit'; limit: NonNullable<IFlexCodeRunFailure['limit']>; message: string }
32
56
  | { kind: 'aborted'; message: string }
33
57
  | { kind: 'host'; error: unknown };
34
58
 
59
+ /** What a worker hands the program it serves: everything but its own bookkeeping. */
60
+ type TCodeRunReport = Exclude<TCodeWorkerReport, { kind: 'released' }>;
61
+
62
+ interface ICodeWorkerClient {
63
+ report(report: TCodeRunReport): void;
64
+ /** The worker failed or exited by itself; it is ended. */
65
+ fail(error: unknown): void;
66
+ }
67
+
35
68
  /** How long calls in flight get to settle after their program stopped and their signal aborted. */
36
69
  const stopGraceMs = 5_000;
37
70
  /**
@@ -40,6 +73,8 @@ const stopGraceMs = 5_000;
40
73
  * array many times over, can run far longer.
41
74
  */
42
75
  const cpuGraceMs = 250;
76
+ /** How long a worker gets to free the runtime of a program that ended by itself before it is ended. */
77
+ const releaseGraceMs = 1_000;
43
78
 
44
79
  const programPrefix = 'globalThis.__flexMain = async function () {\n';
45
80
  const programSuffix = '\n};';
@@ -50,16 +85,32 @@ const workerUrl = new URL(`./classes.codesandbox.worker${plugins.path.extname(im
50
85
  let enginePromise: Promise<plugins.ICodeSandboxEngine> | undefined;
51
86
 
52
87
  /**
53
- * Runs `run_code` programs in QuickJS-ng compiled to WebAssembly: every program in a worker thread and
54
- * WebAssembly instance of its own, with no globals beyond the language, `api`, `ApiError` and
55
- * `console`, and every limit of `IFlexCodeModeLimits` enforced. Program types are stripped with amaro
56
- * first.
88
+ * Runs `run_code` programs in QuickJS-ng compiled to WebAssembly, with no globals beyond the language,
89
+ * `api`, `ApiError` and `console`, and every limit of `IFlexCodeModeLimits` enforced. Program types are
90
+ * stripped with amaro first.
91
+ *
92
+ * The sandbox owns its worker threads. Each runs one program at a time, in a QuickJS runtime and
93
+ * context of its own that it frees once the program ended, so nothing of a program reaches the next,
94
+ * and serves only programs of one scope: programs of different scopes never share a WebAssembly
95
+ * instance. `IFlexCodeSandboxPolicy` says when a worker is replaced; `dispose()` stops the programs
96
+ * running and ends every worker.
57
97
  */
58
98
  export class FlexCodeSandbox {
59
- private constructor(private readonly engine: plugins.ICodeSandboxEngine) {}
99
+ private readonly workers = new Set<CodeWorker>();
100
+ /** Workers waiting for a program, the one that waited longest first. */
101
+ private readonly waiting: CodeWorker[] = [];
102
+ private readonly runs = new Map<CodeRun, Promise<unknown>>();
103
+ private started = 0;
104
+ private closed = false;
105
+ private disposal?: Promise<void>;
106
+
107
+ private constructor(
108
+ private readonly engine: plugins.ICodeSandboxEngine,
109
+ private readonly policy: Readonly<IFlexCodeSandboxPolicy>,
110
+ ) {}
60
111
 
61
- /** Loads QuickJS and amaro, once per process. */
62
- public static async load(): Promise<FlexCodeSandbox> {
112
+ /** Loads QuickJS and amaro, once per process, into a sandbox with workers of its own. */
113
+ public static async load(policy: Partial<IFlexCodeSandboxPolicy> = {}): Promise<FlexCodeSandbox> {
63
114
  enginePromise ??= plugins.importCodeSandboxEngine().catch((error: unknown) => {
64
115
  enginePromise = undefined;
65
116
  throw new FlexHarnessValidationError(
@@ -68,22 +119,224 @@ export class FlexCodeSandbox {
68
119
  { cause: error },
69
120
  );
70
121
  });
71
- return new FlexCodeSandbox(await enginePromise);
122
+ return new FlexCodeSandbox(await enginePromise, { ...FLEX_CODE_SANDBOX_DEFAULT_POLICY, ...policy });
72
123
  }
73
124
 
74
- public run(
125
+ /** Workers started so far, alive now, and waiting for a program. */
126
+ public get workerCounts(): { started: number; live: number; waiting: number } {
127
+ return { started: this.started, live: this.workers.size, waiting: this.waiting.length };
128
+ }
129
+
130
+ /** Starts a worker for the scope's next program, unless one waits for it already. */
131
+ public prewarm(scope?: string): void {
132
+ if (this.closed || this.policy.maxWaiting === 0) return;
133
+ if (this.waiting.some((worker) => worker.programs === 0 || worker.scope === scope)) return;
134
+ this.park(this.spawn());
135
+ }
136
+
137
+ /** Runs a program on a worker that waits for the scope, or on a new one. */
138
+ public async run(
75
139
  source: string,
76
140
  host: IFlexCodeHost,
77
141
  limits: Readonly<IFlexCodeModeLimits>,
78
142
  signal?: AbortSignal,
143
+ scope?: string,
79
144
  ): Promise<TFlexCodeRunResult> {
80
- return new CodeRun(this.engine, source, host, limits, signal).start();
145
+ if (this.closed) throw new FlexHarnessClosedError();
146
+ const run = new CodeRun(this.engine, source, host, limits, signal, () => this.lease(scope));
147
+ const outcome = run.start();
148
+ this.runs.set(run, outcome);
149
+ try {
150
+ return await outcome;
151
+ } finally {
152
+ this.runs.delete(run);
153
+ }
154
+ }
155
+
156
+ /** Stops the programs running, and ends every worker once they settled. */
157
+ public dispose(): Promise<void> {
158
+ if (!this.disposal) {
159
+ this.closed = true;
160
+ this.disposal = this.endAll();
161
+ }
162
+ return this.disposal;
163
+ }
164
+
165
+ private async endAll(): Promise<void> {
166
+ for (const run of this.runs.keys()) run.abort('The code sandbox was disposed.');
167
+ await Promise.allSettled(this.runs.values());
168
+ await Promise.all([...this.workers].map((worker) => worker.end(false)));
169
+ }
170
+
171
+ /** A waiting worker of the scope, the one that waited least; else one that has served no scope yet. */
172
+ private lease(scope: string | undefined): CodeWorker {
173
+ let index = this.lastWaiting((worker) => worker.programs > 0 && worker.scope === scope);
174
+ if (index === -1) index = this.lastWaiting((worker) => worker.programs === 0);
175
+ const worker = index === -1 ? this.spawn() : this.waiting.splice(index, 1)[0]!;
176
+ worker.claim(scope);
177
+ return worker;
178
+ }
179
+
180
+ private lastWaiting(matches: (worker: CodeWorker) => boolean): number {
181
+ for (let index = this.waiting.length - 1; index >= 0; index -= 1) {
182
+ if (matches(this.waiting[index]!)) return index;
183
+ }
184
+ return -1;
185
+ }
186
+
187
+ private spawn(): CodeWorker {
188
+ const worker = new CodeWorker({
189
+ returned: (returned) => this.returned(returned),
190
+ ended: (ended, replace) => this.ended(ended, replace),
191
+ });
192
+ this.workers.add(worker);
193
+ this.started += 1;
194
+ return worker;
195
+ }
196
+
197
+ /** A worker freed its program's runtime: it waits for the next program, or is replaced. */
198
+ private returned(worker: CodeWorker): void {
199
+ if (this.closed) {
200
+ void worker.end(false);
201
+ return;
202
+ }
203
+ if (worker.programs >= this.policy.maxPrograms || worker.memoryBytes > this.policy.maxMemoryBytes) {
204
+ void worker.end(true);
205
+ return;
206
+ }
207
+ this.park(worker);
208
+ }
209
+
210
+ private park(worker: CodeWorker): void {
211
+ worker.wait(this.policy.idleMs);
212
+ this.waiting.push(worker);
213
+ while (this.waiting.length > this.policy.maxWaiting) void this.waiting.shift()!.end(false);
214
+ }
215
+
216
+ /** A worker that served programs and was ended before its time is replaced in the background. */
217
+ private ended(worker: CodeWorker, replace: boolean): void {
218
+ this.workers.delete(worker);
219
+ const index = this.waiting.indexOf(worker);
220
+ if (index !== -1) this.waiting.splice(index, 1);
221
+ if (replace) this.prewarm(worker.scope);
81
222
  }
82
223
  }
83
224
 
84
- class CodeRun {
85
- private worker?: plugins.Worker;
225
+ /**
226
+ * A worker thread of a sandbox, and the WebAssembly instance it loads QuickJS into. It is `waiting`
227
+ * for a program, `serving` one, `releasing` the runtime of a program that ended by itself, or `ended`.
228
+ * Once it served a program, it serves only programs of that program's scope.
229
+ */
230
+ class CodeWorker {
231
+ public state: 'waiting' | 'serving' | 'releasing' | 'ended' = 'waiting';
232
+ public programs = 0;
233
+ public scope?: string;
234
+ /** Its WebAssembly memory after the last program, which never shrinks. */
235
+ public memoryBytes = 0;
236
+ private ready = false;
237
+ private readonly thread: plugins.Worker;
238
+ private client?: ICodeWorkerClient;
239
+ private idleTimer?: ReturnType<typeof setTimeout>;
240
+ private releaseTimer?: ReturnType<typeof setTimeout>;
241
+ /** Settles the `leave()` of the program it releases, once it is returned or ended. */
242
+ private handedBack?: () => void;
86
243
  private terminated?: Promise<unknown>;
244
+
245
+ constructor(private readonly owner: {
246
+ returned(worker: CodeWorker): void;
247
+ ended(worker: CodeWorker, replace: boolean): void;
248
+ }) {
249
+ this.thread = new plugins.Worker(workerUrl);
250
+ this.thread.on('message', (report: TCodeWorkerReport) => this.receive(report));
251
+ this.thread.on('error', (error: unknown) => this.fail(error));
252
+ this.thread.on('exit', (code: number) => this.fail(new Error(`The code mode worker exited with code ${code}.`)));
253
+ }
254
+
255
+ /** Takes the worker out of waiting for a program of the scope. */
256
+ public claim(scope: string | undefined): void {
257
+ clearTimeout(this.idleTimer);
258
+ this.thread.ref();
259
+ this.state = 'serving';
260
+ this.programs += 1;
261
+ this.scope = scope;
262
+ }
263
+
264
+ /** Hands the worker's reports to the program it serves, starting with `ready` once QuickJS is loaded. */
265
+ public serve(client: ICodeWorkerClient): void {
266
+ this.client = client;
267
+ if (this.ready) client.report({ kind: 'ready' });
268
+ }
269
+
270
+ public send(request: TCodeWorkerRequest): void {
271
+ this.thread.postMessage(request);
272
+ }
273
+
274
+ /**
275
+ * The program is over. A worker whose program ended by itself frees its runtime and is returned to
276
+ * the sandbox, or ended if that takes `releaseGraceMs`; any other is ended now, whatever it computes.
277
+ * Settles once the worker is returned or ended, so the program's next one finds it waiting.
278
+ */
279
+ public leave(reusable: boolean): Promise<unknown> {
280
+ this.client = undefined;
281
+ if (!reusable || this.state !== 'serving') return this.end(true);
282
+ this.state = 'releasing';
283
+ this.releaseTimer = setTimeout(() => void this.end(true), releaseGraceMs);
284
+ return new Promise<void>((resolve) => {
285
+ this.handedBack = resolve;
286
+ });
287
+ }
288
+
289
+ /** Waits for the next program, keeping the process alive no longer than its own work does. */
290
+ public wait(idleMs: number): void {
291
+ this.state = 'waiting';
292
+ this.thread.unref();
293
+ this.idleTimer = setTimeout(() => void this.end(false), idleMs);
294
+ this.idleTimer.unref();
295
+ }
296
+
297
+ /** Terminates the thread, which frees its WebAssembly instance; `replace` asks for a successor. */
298
+ public end(replace: boolean): Promise<unknown> {
299
+ if (!this.terminated) {
300
+ this.state = 'ended';
301
+ this.client = undefined;
302
+ clearTimeout(this.idleTimer);
303
+ clearTimeout(this.releaseTimer);
304
+ this.terminated = this.thread.terminate();
305
+ const handedBack = this.handedBack;
306
+ this.handedBack = undefined;
307
+ if (handedBack) void this.terminated.then(handedBack);
308
+ this.owner.ended(this, replace && this.programs > 0);
309
+ }
310
+ return this.terminated;
311
+ }
312
+
313
+ private receive(report: TCodeWorkerReport): void {
314
+ if (report.kind === 'released') {
315
+ if (this.state !== 'releasing') return;
316
+ clearTimeout(this.releaseTimer);
317
+ this.memoryBytes = report.memoryBytes;
318
+ this.owner.returned(this);
319
+ this.handedBack?.();
320
+ this.handedBack = undefined;
321
+ return;
322
+ }
323
+ if (report.kind === 'ready') this.ready = true;
324
+ this.client?.report(report);
325
+ }
326
+
327
+ private fail(error: unknown): void {
328
+ if (this.terminated) return;
329
+ const client = this.client;
330
+ void this.end(true);
331
+ client?.fail(error);
332
+ }
333
+ }
334
+
335
+ class CodeRun {
336
+ private worker?: CodeWorker;
337
+ private left?: Promise<unknown>;
338
+ /** The program ended by itself, so its worker can run the next one. */
339
+ private reusable = false;
87
340
  private program = '';
88
341
  private readonly calls = new AbortController();
89
342
  private readonly queue: Array<() => void> = [];
@@ -111,6 +364,7 @@ class CodeRun {
111
364
  private readonly host: IFlexCodeHost,
112
365
  private readonly limits: Readonly<IFlexCodeModeLimits>,
113
366
  private readonly signal: AbortSignal | undefined,
367
+ private readonly lease: () => CodeWorker,
114
368
  ) {}
115
369
 
116
370
  public async start(): Promise<TFlexCodeRunResult> {
@@ -139,7 +393,7 @@ class CodeRun {
139
393
  this.signal?.addEventListener('abort', onAbort, { once: true });
140
394
  try {
141
395
  if (this.signal?.aborted) onAbort();
142
- else this.spawn();
396
+ else this.attach();
143
397
  return await outcome;
144
398
  } finally {
145
399
  this.signal?.removeEventListener('abort', onAbort);
@@ -147,17 +401,20 @@ class CodeRun {
147
401
  }
148
402
  }
149
403
 
150
- private spawn(): void {
151
- const worker = new plugins.Worker(workerUrl);
152
- this.worker = worker;
153
- worker.on('message', (report: TCodeWorkerReport) => this.receive(report));
154
- worker.on('error', (error: unknown) => this.halt({ kind: 'host', error }));
155
- worker.on('exit', (code: number) => {
156
- this.halt({ kind: 'host', error: new Error(`The code mode worker exited with code ${code}.`) });
404
+ /** Stops the program from outside, as an abort. */
405
+ public abort(message: string): void {
406
+ this.halt({ kind: 'aborted', message });
407
+ }
408
+
409
+ private attach(): void {
410
+ this.worker = this.lease();
411
+ this.worker.serve({
412
+ report: (report) => this.receive(report),
413
+ fail: (error) => this.halt({ kind: 'host', error }),
157
414
  });
158
415
  }
159
416
 
160
- private receive(report: TCodeWorkerReport): void {
417
+ private receive(report: TCodeRunReport): void {
161
418
  if (this.closing) return;
162
419
  switch (report.kind) {
163
420
  case 'ready':
@@ -190,7 +447,7 @@ class CodeRun {
190
447
  this.halt({ kind: 'host', error: new Error(`The code mode prelude failed: ${report.dumped}`) });
191
448
  return;
192
449
  }
193
- this.done(this.failure(this.classify(report, report.phase)));
450
+ this.finish(this.failure(this.classify(report, report.phase)));
194
451
  return;
195
452
  case 'stopped':
196
453
  this.halt(report.limit === 'cpu'
@@ -203,7 +460,7 @@ class CodeRun {
203
460
  /** Has the worker compute: start the program, or go on with an answer. */
204
461
  private send(request: TCodeWorkerRequest): void {
205
462
  this.busy += 1;
206
- this.worker!.postMessage(request);
463
+ this.worker!.send(request);
207
464
  this.armWatchdog();
208
465
  }
209
466
 
@@ -295,13 +552,13 @@ class CodeRun {
295
552
  if (outcome.ok) {
296
553
  if (unawaited > 0) {
297
554
  // Calls the program did not await are not part of its result: it fails, and proposes nothing.
298
- this.done(this.failure({
555
+ this.finish(this.failure({
299
556
  error: 'program_error',
300
557
  message: `The program returned with ${unawaited} call${unawaited === 1 ? '' : 's'} it did not await; await every call.`,
301
558
  }));
302
559
  return;
303
560
  }
304
- this.done({
561
+ this.finish({
305
562
  ok: true,
306
563
  value: outcome.value ?? null,
307
564
  calls: Math.min(this.callCount, this.limits.maxCalls),
@@ -309,7 +566,7 @@ class CodeRun {
309
566
  });
310
567
  return;
311
568
  }
312
- this.done(this.failure(this.classify(outcome, 'program')));
569
+ this.finish(this.failure(this.classify(outcome, 'program')));
313
570
  }
314
571
 
315
572
  private classify(
@@ -363,12 +620,21 @@ class CodeRun {
363
620
  : { error: 'aborted', message: reason.message }));
364
621
  }
365
622
 
623
+ /**
624
+ * The program ended by itself with this outcome. Its worker runs the next program, unless the
625
+ * program reached a limit: such a worker is not trusted with another.
626
+ */
627
+ private finish(result: TFlexCodeRunResult): void {
628
+ this.reusable = result.ok || result.failure.error !== 'limit_exceeded';
629
+ this.done(result);
630
+ }
631
+
366
632
  private done(result: TFlexCodeRunResult): void {
367
633
  this.close(() => this.settle(result));
368
634
  }
369
635
 
370
636
  /**
371
- * Decides the run's outcome and ends the program's worker. Calls not started yet never start. Calls
637
+ * Decides the run's outcome and leaves the program's worker. Calls not started yet never start. Calls
372
638
  * in flight are told to stop through their signal, and the outcome is delivered once they settled,
373
639
  * so no call runs on after its program; a host call that ignores its signal gets `stopGraceMs`, and
374
640
  * its answer is dropped.
@@ -377,7 +643,7 @@ class CodeRun {
377
643
  if (this.closing) return;
378
644
  this.closing = true;
379
645
  this.queue.length = 0;
380
- void this.terminate();
646
+ void this.leave();
381
647
  if (this.inFlight.size === 0) {
382
648
  deliver();
383
649
  return;
@@ -408,11 +674,14 @@ class CodeRun {
408
674
  this.wallTimer = setTimeout(check, Math.max(10, Math.min(250, this.limits.wallMs)));
409
675
  }
410
676
 
411
- /** Ends the program's worker, whatever it computes, which frees its WebAssembly instance with it. */
412
- private terminate(): Promise<unknown> {
677
+ /**
678
+ * Hands the worker back: for the next program if this one ended by itself, ended otherwise, whatever
679
+ * it computes, which frees its WebAssembly instance with it.
680
+ */
681
+ private leave(): Promise<unknown> {
413
682
  clearTimeout(this.watchdog);
414
- this.terminated ??= this.worker?.terminate() ?? Promise.resolve();
415
- return this.terminated;
683
+ this.left ??= this.worker?.leave(this.reusable) ?? Promise.resolve();
684
+ return this.left;
416
685
  }
417
686
 
418
687
  private async dispose(): Promise<void> {
@@ -420,6 +689,6 @@ class CodeRun {
420
689
  if (this.wallTimer) clearTimeout(this.wallTimer);
421
690
  this.queue.length = 0;
422
691
  this.calls.abort();
423
- await this.terminate();
692
+ await this.leave();
424
693
  }
425
694
  }
@@ -1,8 +1,9 @@
1
1
  /**
2
- * The QuickJS side of a `run_code` program. `FlexCodeSandbox` runs every program in a worker thread of
3
- * its own with this module, and ends it by terminating the worker, which frees its WebAssembly instance
4
- * too: a native operation that never reaches QuickJS's interrupt handler cannot outlast its limits, and
5
- * a program never blocks the host's event loop.
2
+ * The QuickJS side of `run_code` programs. A worker thread of `FlexCodeSandbox` loads QuickJS once and
3
+ * runs one program at a time, each in a QuickJS runtime and context of its own, which it frees once the
4
+ * program ended by itself. A program stopped at a limit ends with its worker: terminating the worker
5
+ * frees its WebAssembly instance too, so a native operation that never reaches QuickJS's interrupt
6
+ * handler cannot outlast its limits, and a program never blocks the host's event loop.
6
7
  *
7
8
  * Written in erasable TypeScript that imports nothing of the package at run time, so the module loads
8
9
  * uncompiled as well.
@@ -29,10 +30,13 @@ export type TCodeWorkerRequest =
29
30
  /**
30
31
  * What a program's worker reports. `ready` comes once QuickJS is loaded; every `start` and `answer`
31
32
  * ends in `idle`, with the milliseconds computed so far, unless the program stopped. `done` counts the
32
- * calls the program had no answer to when it returned.
33
+ * calls the program had no answer to when it returned. `released` follows a program that ended by
34
+ * itself (`done`, or `failed` short of a limit): its runtime is freed, and the worker's WebAssembly
35
+ * memory, which never shrinks, has `memoryBytes`.
33
36
  */
34
37
  export type TCodeWorkerReport =
35
38
  | { kind: 'ready' }
39
+ | { kind: 'released'; memoryBytes: number }
36
40
  | { kind: 'call'; id: number; method: string; params: string }
37
41
  | { kind: 'log'; text: string }
38
42
  | { kind: 'idle'; cpuMs: number }
@@ -134,7 +138,8 @@ const prelude = `'use strict';
134
138
  /**
135
139
  * One program in its QuickJS runtime. It enforces the limits QuickJS can see: memory, stack, console
136
140
  * output, and computing time at every interrupt point. The host enforces the rest, and ends the worker
137
- * when the program computes past its time between two interrupt points.
141
+ * when the program computes past its time between two interrupt points. Once the program's outcome is
142
+ * reported, nothing more of it runs: work it left pending is interrupted and dropped with its runtime.
138
143
  */
139
144
  class CodeWorkerRun {
140
145
  private readonly report: (report: TCodeWorkerReport) => void;
@@ -177,6 +182,19 @@ class CodeWorkerRun {
177
182
  this.idle();
178
183
  }
179
184
 
185
+ /** The program ended by itself, so its runtime can be freed and the worker run the next program. */
186
+ public get finished(): boolean {
187
+ return this.ended && this.stopped === undefined;
188
+ }
189
+
190
+ /** Frees the program's runtime with everything in it, and the calls it left unanswered. */
191
+ public release(): void {
192
+ for (const deferred of this.deferreds.values()) deferred.dispose();
193
+ this.deferreds.clear();
194
+ this.vm.dispose();
195
+ this.runtime.dispose();
196
+ }
197
+
180
198
  /** Settles a call of the program with the host's outcome, and runs what it unblocked. */
181
199
  public answer(id: number, text: string): void {
182
200
  const deferred = this.deferreds.get(id);
@@ -233,7 +251,7 @@ class CodeWorkerRun {
233
251
  return false;
234
252
  }
235
253
  result.value.dispose();
236
- return true;
254
+ return !this.ended;
237
255
  }
238
256
 
239
257
  /** Runs the program's pending jobs after a call settled or the program started. */
@@ -259,6 +277,11 @@ class CodeWorkerRun {
259
277
  }
260
278
 
261
279
  private failWith(handle: plugins.IQuickJsHandle, phase: TCodeWorkerPhase): void {
280
+ if (this.ended) {
281
+ // The interruption of work the program left pending once its outcome was out.
282
+ handle.dispose();
283
+ return;
284
+ }
262
285
  let dumped: unknown;
263
286
  // Reading the thrown value can run program code, such as a toJSON: it counts as computing.
264
287
  this.enter();
@@ -268,6 +291,7 @@ class CodeWorkerRun {
268
291
  dumped = undefined;
269
292
  } finally {
270
293
  this.leave();
294
+ handle.dispose();
271
295
  }
272
296
  if (this.stopped) {
273
297
  this.stop(this.stopped);
@@ -313,7 +337,7 @@ class CodeWorkerRun {
313
337
  }
314
338
 
315
339
  private interrupted(): boolean {
316
- if (this.stopped) return true;
340
+ if (this.stopped || this.ended) return true;
317
341
  if (this.enteredAt !== undefined && this.cpuMs + performance.now() - this.enteredAt > this.limits.cpuMs) {
318
342
  this.stopped = 'cpu';
319
343
  return true;
@@ -340,6 +364,12 @@ if (port) {
340
364
  } else {
341
365
  run?.answer(request.id, request.text);
342
366
  }
367
+ // A failure to free the runtime throws: the worker fails, and the host ends it.
368
+ if (run?.finished) {
369
+ run.release();
370
+ run = undefined;
371
+ report({ kind: 'released', memoryBytes: module.getWasmMemory().buffer.byteLength });
372
+ }
343
373
  });
344
374
  report({ kind: 'ready' });
345
375
  }
@@ -39,7 +39,7 @@ export class FlexHarnessRunCallbacks<TScope> {
39
39
  const run = this.callbackRun(state, sessionId);
40
40
  if (!run || !delta) return;
41
41
  const lastPart = run.callbackParts.at(-1);
42
- const openPart = lastPart?.type === 'text' && !run.completedTextPartIds.has(lastPart.partId)
42
+ const openPart = lastPart?.type === 'text' && run.stepPartIds.has(lastPart.partId)
43
43
  ? lastPart
44
44
  : undefined;
45
45
  const created = !openPart;
@@ -54,6 +54,7 @@ export class FlexHarnessRunCallbacks<TScope> {
54
54
  )) return;
55
55
  if (created) {
56
56
  run.callbackParts.push(part);
57
+ run.stepPartIds.add(part.partId);
57
58
  run.partTextUtf8Bytes.set(part.partId, 0);
58
59
  this.core.emitPartEvent(run, 'part.started', part);
59
60
  }
@@ -75,6 +76,7 @@ export class FlexHarnessRunCallbacks<TScope> {
75
76
  };
76
77
  run.reasoningPartIds.set(id, part.partId);
77
78
  run.callbackParts.push(part);
79
+ run.stepPartIds.add(part.partId);
78
80
  run.partTextUtf8Bytes.set(part.partId, 0);
79
81
  this.core.emitPartEvent(run, 'part.started', part);
80
82
  }
@@ -115,6 +117,9 @@ export class FlexHarnessRunCallbacks<TScope> {
115
117
  }
116
118
  part.status = 'completed';
117
119
  part.completedAt = new Date().toISOString();
120
+ // Model calls number their reasoning summaries afresh, so a later call's summary with the
121
+ // same ID is another one.
122
+ run.reasoningPartIds.delete(id);
118
123
  this.core.emitPartEvent(run, 'part.completed', part);
119
124
  }
120
125
 
@@ -128,10 +133,10 @@ export class FlexHarnessRunCallbacks<TScope> {
128
133
  const run = this.callbackRun(state, sessionId);
129
134
  if (!run) return;
130
135
  for (const part of run.callbackParts) {
131
- if (part.type !== 'text' || run.completedTextPartIds.has(part.partId)) continue;
132
- run.completedTextPartIds.add(part.partId);
136
+ if (part.type !== 'text' || !run.stepPartIds.has(part.partId)) continue;
133
137
  this.core.emitPartEvent(run, 'part.completed', part);
134
138
  }
139
+ run.stepPartIds.clear();
135
140
  }
136
141
 
137
142
  private textAppendAccounting(
@@ -372,9 +377,23 @@ export class FlexHarnessRunCallbacks<TScope> {
372
377
  }
373
378
  }
374
379
 
380
+ /**
381
+ * Announces a retry. The failed model call's text and reasoning are not in the session, so their
382
+ * parts are removed first, last part first, each with a `part.removed` event; its tool calls
383
+ * stay. The removals are outside the callback limits, like completions: their number is bounded
384
+ * by the run's parts. What the removed parts took of the limits stays taken.
385
+ */
375
386
  public onRetry(state: IStorageState, sessionId: string, retry: plugins.IAgentRetryEvent): void {
376
387
  const run = this.callbackRun(state, sessionId);
377
388
  if (!run || !this.reserveCallbackCapacity(run, 1, 0, 0)) return;
389
+ for (let partIndex = run.callbackParts.length - 1; partIndex >= 0; partIndex--) {
390
+ const part = run.callbackParts[partIndex]!;
391
+ if (!run.stepPartIds.has(part.partId)) continue;
392
+ this.core.emitPartRemovedEvent(run, part);
393
+ run.callbackParts.splice(partIndex, 1);
394
+ run.partTextUtf8Bytes.delete(part.partId);
395
+ }
396
+ run.stepPartIds.clear();
378
397
  this.core.emitEvent(run.scopeId, run.stored.session, {
379
398
  type: 'run.retrying',
380
399
  runId: run.runId,