@modelprofile.com/flexharness 11.1.1 → 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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.codesandbox.d.ts +58 -7
- package/dist_ts/classes.codesandbox.js +267 -34
- package/dist_ts/classes.codesandbox.worker.d.ts +6 -1
- package/dist_ts/classes.codesandbox.worker.js +34 -8
- package/dist_ts/classes.flexharness.callbacks.d.ts +6 -0
- package/dist_ts/classes.flexharness.callbacks.js +24 -4
- package/dist_ts/classes.flexharness.core.d.ts +8 -0
- package/dist_ts/classes.flexharness.core.js +29 -1
- package/dist_ts/classes.flexharness.lifecycle.js +7 -1
- package/dist_ts/classes.flexharness.runs.js +9 -7
- package/dist_ts/classes.flexharness.tooldispatch.js +2 -2
- package/dist_ts/interfaces.d.ts +9 -1
- package/dist_ts/interfaces.flexharness.d.ts +5 -3
- package/dist_ts/plugins.d.ts +3 -1
- package/dist_ts/plugins.js +2 -2
- package/dist_ts_chat_harness/classes.transcript.d.ts +1 -0
- package/dist_ts_chat_harness/classes.transcript.js +16 -1
- package/package.json +3 -3
- package/readme.md +5 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.codesandbox.ts +303 -34
- package/ts/classes.codesandbox.worker.ts +38 -8
- package/ts/classes.flexharness.callbacks.ts +22 -3
- package/ts/classes.flexharness.core.ts +31 -0
- package/ts/classes.flexharness.lifecycle.ts +5 -0
- package/ts/classes.flexharness.runs.ts +8 -5
- package/ts/classes.flexharness.tooldispatch.ts +1 -1
- package/ts/interfaces.flexharness.ts +5 -3
- package/ts/interfaces.ts +10 -1
- package/ts/plugins.ts +3 -1
- package/ts/readme.md +5 -2
- 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
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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.
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
this.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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:
|
|
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.
|
|
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!.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
412
|
-
|
|
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.
|
|
415
|
-
return this.
|
|
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.
|
|
692
|
+
await this.leave();
|
|
424
693
|
}
|
|
425
694
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The QuickJS side of
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* a
|
|
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
|
|
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' &&
|
|
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.
|
|
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,
|