@gobing-ai/ts-laya-mlx 0.5.1

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.
@@ -0,0 +1,592 @@
1
+ /**
2
+ * Process-lifecycle client for the laya JSON-lines worker (task 0076).
3
+ *
4
+ * Spawns `worker/laya_worker.py` through ts-runtime's `ProcessExecutor.runStreaming`
5
+ * (ADR-011/ADR-014 — the only sanctioned spawn seam), awaits the startup handshake
6
+ * before the first request, correlates responses by id, and enforces two independent
7
+ * budgets: `startupTimeoutMs` for the one-time weight load, `requestTimeoutMs` for a
8
+ * single forward pass. The worker is started lazily on the first ask and held for the
9
+ * client's lifetime; a worker that dies rejects every in-flight call and the next ask
10
+ * starts a fresh process — never a silent mid-ask restart. Only documented env keys
11
+ * reach the child, so the injected-env contract (ADR-011) holds across the process
12
+ * boundary. Protocol reference: docs/design/laya-local-decision-backend.md.
13
+ */
14
+ import {
15
+ DecisionAuthError,
16
+ DecisionBackendError,
17
+ DecisionConfigError,
18
+ DecisionConnectionError,
19
+ type DecisionError,
20
+ DecisionRequestError,
21
+ DecisionTimeoutError,
22
+ } from '@gobing-ai/ts-ai-runner';
23
+ import { joinPath, nodeBunFactory, type PipeProcess, type ProcessExecutor } from '@gobing-ai/ts-runtime';
24
+
25
+ /** Environment keys this package may pass to the child process — the full allowlist. */
26
+ const FORWARDED_ENV_KEYS = ['LAYA_MODEL_ID', 'LAYA_MODEL_PATH', 'LAYA_CACHE_DIR', 'LAYA_PYTHON', 'HF_TOKEN'] as const;
27
+
28
+ /** How many trailing stderr bytes to keep for exit-failure messages. */
29
+ const STDERR_TAIL_LIMIT = 2000;
30
+
31
+ /** Grace window for the exit event once stdout closes, so pending calls reject with the exit-informed error. */
32
+ const STREAM_CLOSE_GRACE_MS = 250;
33
+
34
+ /** Configuration options for {@link LayaWorkerClient}. */
35
+ export interface LayaWorkerClientOptions {
36
+ /** Interpreter that carries `laya-mlx`. Default: `'python3'`. */
37
+ pythonPath?: string;
38
+ /** Runtime module the worker imports. Default: `'laya_mlx'`. */
39
+ module?: string;
40
+ /** Worker script path. Default: `worker/laya_worker.py` in this package. */
41
+ workerScript?: string;
42
+ /** Working directory for the spawn. Default: this package root. */
43
+ cwd?: string;
44
+ /** Hugging Face model id. Default: worker-side (the runtime's own default). */
45
+ modelId?: string;
46
+ /** Local checkpoint directory; wins over `modelId`. */
47
+ modelPath?: string;
48
+ /** Checkpoint revision pinned for resolution. */
49
+ revision?: string;
50
+ /** Artifact cache root, exported as `HF_HUB_CACHE` inside the worker. */
51
+ cacheDir?: string;
52
+ /** Weight precision. Default: `'float16'`. */
53
+ dtype?: 'float32' | 'float16' | 'bfloat16';
54
+ /** Questions per forward pass. Default: `16`. */
55
+ batchSize?: number;
56
+ /** Budget for the startup handshake, which covers the one-time weight load. Default: `120_000`. */
57
+ startupTimeoutMs?: number;
58
+ /** Budget for one request once the worker is ready. Default: `30_000`. */
59
+ requestTimeoutMs?: number;
60
+ /** Injected environment record — this package never reads the ambient environment (ADR-011). */
61
+ env?: Record<string, string | undefined>;
62
+ /** Platform name for prerequisite validation. Default: `process.platform`. */
63
+ platform?: string;
64
+ /** Architecture name for prerequisite validation. Default: `process.arch`. */
65
+ arch?: string;
66
+ }
67
+
68
+ /**
69
+ * Project the injected environment onto the child allowlist. Everything absent from
70
+ * `FORWARDED_ENV_KEYS` is dropped, so the parent environment is never inherited
71
+ * wholesale and the child gets exactly the documented keys that are set.
72
+ */
73
+ export function resolveForwardedEnv(env: Record<string, string | undefined> | undefined): Record<string, string> {
74
+ const forwarded: Record<string, string> = {};
75
+ if (!env) return forwarded;
76
+ for (const key of FORWARDED_ENV_KEYS) {
77
+ const value = env[key];
78
+ if (value !== undefined) forwarded[key] = value;
79
+ }
80
+ return forwarded;
81
+ }
82
+
83
+ /** Worker-level question — the reference format `predict` consumes. Neutral-shape mapping lands with the driver facade. */
84
+ export interface WorkerQuestion {
85
+ type: 'choice' | 'score' | 'noul';
86
+ instructions: string;
87
+ [key: string]: unknown;
88
+ }
89
+
90
+ /** The runtime's own `predict` payload, passed through unaltered. */
91
+ export interface LayaWorkerResult {
92
+ model: string;
93
+ answers: Record<string, unknown>;
94
+ usage?: Record<string, unknown>;
95
+ }
96
+
97
+ /** Startup handshake emitted once before any request is served. */
98
+ interface WorkerHandshake {
99
+ ready: boolean;
100
+ model?: string;
101
+ revision?: string | null;
102
+ maxLen?: number;
103
+ error?: { kind: string; message: string };
104
+ }
105
+
106
+ /** One parsed stdout line; open-ended, so known fields are typed and the rest indexed. */
107
+ interface WorkerLine {
108
+ id?: string | number;
109
+ ok?: boolean;
110
+ result?: LayaWorkerResult;
111
+ error?: { kind: string; message: string };
112
+ }
113
+
114
+ /**
115
+ * Validate host platform prerequisites for `@gobing-ai/ts-laya-mlx`.
116
+ * Throws {@link DecisionConfigError} if the platform is not Apple Silicon macOS.
117
+ */
118
+ export function validateHostPrerequisites(platform: string = process.platform, arch: string = process.arch): void {
119
+ if (platform !== 'darwin' || arch !== 'arm64') {
120
+ throw new DecisionConfigError(
121
+ `laya-mlx requires macOS on Apple Silicon (darwin arm64); current platform is '${platform} ${arch}'`,
122
+ 'PLATFORM',
123
+ );
124
+ }
125
+ }
126
+
127
+ function hasNonFiniteValues(obj: unknown): boolean {
128
+ if (typeof obj === 'number') {
129
+ return !Number.isFinite(obj);
130
+ }
131
+ if (obj !== null && typeof obj === 'object') {
132
+ for (const val of Object.values(obj as Record<string, unknown>)) {
133
+ if (hasNonFiniteValues(val)) return true;
134
+ }
135
+ }
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * Map worker-reported error kinds and messages onto the decision taxonomy (design: Errors table, task 0077).
141
+ */
142
+ export function translateWorkerError(kind: string | undefined, message: string): DecisionError {
143
+ const lower = message.toLowerCase();
144
+ // Credential/auth failures during artifact resolution (0077 R6)
145
+ if (
146
+ lower.includes('401') ||
147
+ lower.includes('unauthorized') ||
148
+ lower.includes('403') ||
149
+ lower.includes('forbidden') ||
150
+ lower.includes('gated repo') ||
151
+ lower.includes('hf_token') ||
152
+ lower.includes('invalid credentials')
153
+ ) {
154
+ const status = lower.includes('403') || lower.includes('forbidden') ? 403 : 401;
155
+ return new DecisionAuthError(`laya artifact resolution failed: ${message}`, status);
156
+ }
157
+ // Transport/connection failures during artifact resolution (0077 R6)
158
+ if (
159
+ lower.includes('connectionerror') ||
160
+ lower.includes('connection refused') ||
161
+ lower.includes('name resolution') ||
162
+ lower.includes('nameresolutionerror') ||
163
+ lower.includes('network is unreachable') ||
164
+ lower.includes('could not connect') ||
165
+ lower.includes('transport')
166
+ ) {
167
+ return new DecisionConnectionError(`laya artifact resolution failed: ${message}`);
168
+ }
169
+ // Missing runtime (0077 R2)
170
+ if (lower.includes('no module named') || lower.includes('laya_mlx') || lower.includes('laya-mlx')) {
171
+ return new DecisionConfigError(
172
+ `laya-mlx runtime is not importable: ${message}; install with 'pip install laya-mlx'`,
173
+ 'LAYA_PYTHON',
174
+ );
175
+ }
176
+ // Non-finite output (0077 R5)
177
+ if (
178
+ lower.includes('non-finite') ||
179
+ lower.includes('floatingpointerror') ||
180
+ lower.includes('nan') ||
181
+ lower.includes('infinity')
182
+ ) {
183
+ return new DecisionBackendError(`laya worker non-finite output: ${message}`, undefined);
184
+ }
185
+ switch (kind) {
186
+ case 'config':
187
+ return new DecisionConfigError(`laya worker rejected its configuration: ${message}`, 'laya-worker');
188
+ case 'request':
189
+ return new DecisionRequestError(`laya worker rejected the request: ${message}`, undefined, message);
190
+ default:
191
+ return new DecisionBackendError(`laya worker backend failure: ${message}`, undefined);
192
+ }
193
+ }
194
+
195
+ /** Map a worker-reported error kind onto the decision taxonomy (design: Errors table). */
196
+ function toWorkerError(kind: string | undefined, message: string): DecisionError {
197
+ return translateWorkerError(kind, message);
198
+ }
199
+
200
+ interface PendingEntry {
201
+ resolve: (result: LayaWorkerResult) => void;
202
+ reject: (error: Error) => void;
203
+ timer: ReturnType<typeof setTimeout>;
204
+ }
205
+
206
+ /**
207
+ * A started worker process plus its line pump, pending-request map, handshake
208
+ * race, and exit watcher. The pump owns the stdout stream exclusively: the first
209
+ * line settles the handshake, every later line dispatches by id.
210
+ */
211
+ class WorkerHandle {
212
+ readonly proc: PipeProcess;
213
+ readonly handshake: Promise<WorkerHandshake>;
214
+ private readonly requestTimeoutMs: number;
215
+ private readonly pending = new Map<string, PendingEntry>();
216
+ private nextRequestId = 1;
217
+ private alive = true;
218
+ private stderrTail = '';
219
+ private handshakeSettled = false;
220
+ private handshakeTimer: ReturnType<typeof setTimeout> | null = null;
221
+ private handshakeResolve: ((handshake: WorkerHandshake) => void) | null = null;
222
+ private handshakeReject: ((error: Error) => void) | null = null;
223
+ private streamCloseTimer: ReturnType<typeof setTimeout> | null = null;
224
+
225
+ constructor(proc: PipeProcess, requestTimeoutMs: number, startupTimeoutMs: number) {
226
+ this.proc = proc;
227
+ this.requestTimeoutMs = requestTimeoutMs;
228
+ this.handshake = new Promise<WorkerHandshake>((resolve, reject) => {
229
+ this.handshakeResolve = resolve;
230
+ this.handshakeReject = reject;
231
+ });
232
+ this.handshakeTimer = setTimeout(() => {
233
+ this.failHandshake(
234
+ new DecisionTimeoutError(
235
+ `laya worker did not emit its handshake within startupTimeoutMs (${startupTimeoutMs})`,
236
+ startupTimeoutMs,
237
+ ),
238
+ );
239
+ }, startupTimeoutMs);
240
+ if (proc.stdout !== null) this.pump(proc.stdout);
241
+ if (proc.stderr !== null) this.collectStderr(proc.stderr);
242
+ void proc.exited.then((code) => {
243
+ this.alive = false;
244
+ this.failHandshake(this.exitError(code));
245
+ this.failAll(this.exitError(code));
246
+ });
247
+ }
248
+
249
+ /** Reject everything in flight; the map clear makes repeated calls harmless. */
250
+ failAll(error: Error): void {
251
+ this.alive = false;
252
+ for (const entry of this.pending.values()) {
253
+ clearTimeout(entry.timer);
254
+ entry.reject(error);
255
+ }
256
+ this.pending.clear();
257
+ }
258
+
259
+ /** Close stdin for a graceful EOF exit. */
260
+ end(): void {
261
+ try {
262
+ this.proc.endStdin();
263
+ } catch {
264
+ // Already dead — the exit watcher owns failure propagation from here.
265
+ }
266
+ }
267
+
268
+ /** Terminate a worker that must not keep running (startup failure). */
269
+ kill(): void {
270
+ try {
271
+ this.proc.kill();
272
+ } catch {
273
+ // Already dead — the exit watcher owns failure propagation from here.
274
+ }
275
+ }
276
+
277
+ get isAlive(): boolean {
278
+ return this.alive;
279
+ }
280
+
281
+ /**
282
+ * Register a pending entry under the request timeout and return its correlation
283
+ * id. Registered before the write so the exit watcher covers the send window.
284
+ */
285
+ armRequest(resolve: (result: LayaWorkerResult) => void, reject: (error: Error) => void): string {
286
+ const id = String(this.nextRequestId++);
287
+ const requestTimeoutMs = this.requestTimeoutMs;
288
+ const timer = setTimeout(() => {
289
+ // Reject this request only; the stream keeps running and the late
290
+ // response is dropped by id when it arrives (no queue corruption).
291
+ this.pending.delete(id);
292
+ reject(
293
+ new DecisionTimeoutError(
294
+ `laya worker request ${id} exceeded requestTimeoutMs (${requestTimeoutMs})`,
295
+ requestTimeoutMs,
296
+ ),
297
+ );
298
+ }, requestTimeoutMs);
299
+ this.pending.set(id, { resolve, reject, timer });
300
+ return id;
301
+ }
302
+
303
+ private exitError(code: number | null): DecisionError {
304
+ const tail = this.stderrTail.length > 0 ? `: ${this.stderrTail}` : '';
305
+ const raw = `${this.stderrTail} (code ${code ?? 'unknown'})`;
306
+ if (raw.includes('No module named') || raw.includes('laya_mlx')) {
307
+ return new DecisionConfigError(
308
+ `laya-mlx runtime is not importable: ${raw}; install with 'pip install laya-mlx'`,
309
+ 'LAYA_PYTHON',
310
+ );
311
+ }
312
+ return new DecisionBackendError(
313
+ `laya worker exited unexpectedly (code ${code ?? 'unknown'})${tail}`,
314
+ undefined,
315
+ );
316
+ }
317
+
318
+ private failHandshake(error: Error): void {
319
+ if (this.handshakeSettled) return;
320
+ this.handshakeSettled = true;
321
+ if (this.handshakeTimer !== null) clearTimeout(this.handshakeTimer);
322
+ this.handshakeReject?.(error);
323
+ }
324
+
325
+ /** Read stdout as UTF-8 JSON Lines; first line settles the handshake, the rest dispatch by id. */
326
+ private pump(stream: ReadableStream<Uint8Array>): void {
327
+ const decoder = new TextDecoder();
328
+ let buffer = '';
329
+ void (async () => {
330
+ for await (const chunk of stream) {
331
+ buffer += decoder.decode(chunk, { stream: true });
332
+ let newlineAt = buffer.indexOf('\n');
333
+ while (newlineAt !== -1) {
334
+ this.onLine(buffer.slice(0, newlineAt));
335
+ buffer = buffer.slice(newlineAt + 1);
336
+ newlineAt = buffer.indexOf('\n');
337
+ }
338
+ }
339
+ buffer += decoder.decode();
340
+ if (buffer.trim().length > 0) this.onLine(buffer);
341
+ this.onStreamClosed();
342
+ })().catch(() => {
343
+ this.failAll(new DecisionBackendError('laya worker output stream failed', undefined));
344
+ });
345
+ }
346
+
347
+ /**
348
+ * Stdout closed: a dead worker's pipes drain before its exit event resolves, so
349
+ * defer to the exit watcher — pending calls must reject with the exit-informed
350
+ * error (exit code + stderr tail), not a generic stream close. The grace timer
351
+ * bounds the wait for a worker that closed its stream but kept running; those
352
+ * calls fall back to the close rejection instead of hanging to their timeouts.
353
+ */
354
+ private onStreamClosed(): void {
355
+ this.streamCloseTimer = setTimeout(() => {
356
+ this.streamCloseTimer = null;
357
+ this.failAll(new DecisionBackendError('laya worker closed its output stream', undefined));
358
+ }, STREAM_CLOSE_GRACE_MS);
359
+ void this.proc.exited.then((code) => {
360
+ if (this.streamCloseTimer !== null) {
361
+ clearTimeout(this.streamCloseTimer);
362
+ this.streamCloseTimer = null;
363
+ }
364
+ this.failAll(this.exitError(code));
365
+ });
366
+ }
367
+
368
+ private onLine(line: string): void {
369
+ if (!this.handshakeSettled) {
370
+ this.settleHandshake(line);
371
+ return;
372
+ }
373
+ let parsed: WorkerLine;
374
+ try {
375
+ parsed = JSON.parse(line) as WorkerLine;
376
+ } catch {
377
+ // Stream framing is broken — no pending request can trust what follows.
378
+ this.failAll(
379
+ new DecisionBackendError(`laya worker emitted an unparseable line: ${line.slice(0, 200)}`, undefined),
380
+ );
381
+ this.kill();
382
+ return;
383
+ }
384
+ if (parsed.id === undefined) return; // stray line without correlation — dropped by design
385
+ const entry = this.pending.get(String(parsed.id));
386
+ if (entry === undefined) return; // late response for an already-rejected request
387
+ this.pending.delete(String(parsed.id));
388
+ clearTimeout(entry.timer);
389
+ if (parsed.ok === true && parsed.result !== undefined) {
390
+ if (hasNonFiniteValues(parsed.result.answers)) {
391
+ entry.reject(
392
+ new DecisionBackendError(
393
+ "laya worker returned non-finite model outputs; retry with dtype='float32'",
394
+ undefined,
395
+ ),
396
+ );
397
+ return;
398
+ }
399
+ entry.resolve(parsed.result);
400
+ return;
401
+ }
402
+ if (parsed.ok === false) {
403
+ entry.reject(toWorkerError(parsed.error?.kind, parsed.error?.message ?? 'unknown worker error'));
404
+ return;
405
+ }
406
+ entry.reject(
407
+ new DecisionBackendError(
408
+ `laya worker sent a malformed response for request ${String(parsed.id)}`,
409
+ undefined,
410
+ ),
411
+ );
412
+ }
413
+
414
+ /** First stdout line: validate the handshake envelope and settle the startup race. */
415
+ private settleHandshake(line: string): void {
416
+ if (this.handshakeSettled) return; // the timeout or the exit watcher won the race
417
+ this.handshakeSettled = true;
418
+ if (this.handshakeTimer !== null) clearTimeout(this.handshakeTimer);
419
+ let handshake: WorkerHandshake;
420
+ try {
421
+ handshake = JSON.parse(line) as WorkerHandshake;
422
+ } catch {
423
+ this.handshakeReject?.(
424
+ new DecisionBackendError(`laya worker handshake was not JSON: ${line.slice(0, 200)}`, undefined),
425
+ );
426
+ return;
427
+ }
428
+ if (handshake.ready === true) {
429
+ this.handshakeResolve?.(handshake);
430
+ return;
431
+ }
432
+ this.handshakeReject?.(
433
+ toWorkerError(
434
+ handshake.error?.kind ?? 'config',
435
+ handshake.error?.message ?? 'worker reported it was not ready',
436
+ ),
437
+ );
438
+ }
439
+
440
+ /** Drain stderr so a chatty worker cannot block, keeping a tail for exit diagnostics. */
441
+ private collectStderr(stream: ReadableStream<Uint8Array>): void {
442
+ const decoder = new TextDecoder();
443
+ void (async () => {
444
+ for await (const chunk of stream) {
445
+ this.stderrTail = `${this.stderrTail}${decoder.decode(chunk, { stream: true })}`.slice(
446
+ -STDERR_TAIL_LIMIT,
447
+ );
448
+ }
449
+ })().catch(() => {
450
+ // Stderr diagnostics are best-effort only.
451
+ });
452
+ }
453
+ }
454
+
455
+ /**
456
+ * TypeScript half of the laya process bridge: start the Python worker lazily,
457
+ * hold it warm for the client's lifetime, and serve serialized asks with
458
+ * id-correlated, timeout-bounded responses.
459
+ */
460
+ export class LayaWorkerClient {
461
+ private readonly pythonPath: string;
462
+ private readonly args: string[];
463
+ private readonly cwd: string;
464
+ private readonly startupTimeoutMs: number;
465
+ private readonly requestTimeoutMs: number;
466
+ private readonly forwardedEnv: Record<string, string>;
467
+ private readonly executor: ProcessExecutor;
468
+ private worker: WorkerHandle | null = null;
469
+ private chain: Promise<unknown> = Promise.resolve();
470
+ private disposed = false;
471
+
472
+ constructor(
473
+ options: LayaWorkerClientOptions = {},
474
+ executor: ProcessExecutor = nodeBunFactory.createProcessExecutor(),
475
+ ) {
476
+ validateHostPrerequisites(options.platform, options.arch);
477
+ const packageRoot = joinPath(import.meta.dir, '..');
478
+ this.pythonPath = options.pythonPath ?? options.env?.LAYA_PYTHON ?? 'python3';
479
+ this.cwd = options.cwd ?? packageRoot;
480
+ this.startupTimeoutMs = options.startupTimeoutMs ?? 120_000;
481
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
482
+ this.forwardedEnv = resolveForwardedEnv(options.env);
483
+ this.executor = executor;
484
+ this.args = [options.workerScript ?? joinPath(packageRoot, 'worker', 'laya_worker.py')];
485
+ this.args.push('--module', options.module ?? 'laya_mlx');
486
+ const resolvedModelPath = options.modelPath ?? options.env?.LAYA_MODEL_PATH;
487
+ const resolvedCacheDir = options.cacheDir ?? options.env?.LAYA_CACHE_DIR;
488
+ const resolvedModelId = options.modelId ?? options.env?.LAYA_MODEL_ID ?? 'convaiinnovations/laya-multilingual';
489
+
490
+ if (resolvedModelPath !== undefined) {
491
+ this.args.push('--model-path', resolvedModelPath);
492
+ } else {
493
+ this.args.push('--model', resolvedModelId);
494
+ }
495
+ if (options.revision !== undefined) this.args.push('--revision', options.revision);
496
+ this.args.push('--dtype', options.dtype ?? 'float16');
497
+ this.args.push('--batch-size', String(options.batchSize ?? 16));
498
+ if (resolvedCacheDir !== undefined) this.args.push('--cache-dir', resolvedCacheDir);
499
+ }
500
+
501
+ /**
502
+ * Ask one batch of worker-format questions. Requests are serialized — the
503
+ * worker serves one request at a time and batching happens inside a single
504
+ * request — and the worker spawns lazily on the first call, so the weight
505
+ * load is paid once per client.
506
+ */
507
+ ask(state: string, questions: Record<string, WorkerQuestion>): Promise<LayaWorkerResult> {
508
+ const run = this.chain.then(() => this.dispatch(state, questions));
509
+ this.chain = run.then(
510
+ () => undefined,
511
+ () => undefined,
512
+ );
513
+ return run;
514
+ }
515
+
516
+ /** Close the worker; safe to call repeatedly. In-flight calls reject through the exit watcher. */
517
+ dispose(): void {
518
+ this.disposed = true;
519
+ const worker = this.worker;
520
+ this.worker = null;
521
+ worker?.end();
522
+ }
523
+
524
+ private async dispatch(state: string, questions: Record<string, WorkerQuestion>): Promise<LayaWorkerResult> {
525
+ if (this.disposed) throw new DecisionBackendError('laya worker client has been disposed', undefined);
526
+ const worker = await this.ensureWorker();
527
+ return new Promise<LayaWorkerResult>((resolve, reject) => {
528
+ const id = worker.armRequest(resolve, reject);
529
+ try {
530
+ worker.proc.writeStdin(`${JSON.stringify({ id, state, questions })}\n`);
531
+ } catch (cause) {
532
+ reject(new DecisionBackendError('laya worker rejected the request line', undefined, { cause }));
533
+ return;
534
+ }
535
+ if (!worker.isAlive) {
536
+ // The exit watcher raced the write; fail now instead of at the timeout.
537
+ worker.failAll(
538
+ new DecisionBackendError('laya worker exited before the request could be sent', undefined),
539
+ );
540
+ }
541
+ });
542
+ }
543
+
544
+ private async ensureWorker(): Promise<WorkerHandle> {
545
+ if (this.worker?.isAlive) return this.worker;
546
+ this.worker = null;
547
+ const worker = this.spawn();
548
+ try {
549
+ await worker.handshake;
550
+ } catch (error) {
551
+ // Startup failed: nothing is in flight, so terminate and surface the
552
+ // error. The next ask starts fresh rather than reusing a dead handle.
553
+ worker.kill();
554
+ throw error;
555
+ }
556
+ if (this.disposed) {
557
+ // dispose() raced the startup handshake — the worker must not outlive
558
+ // a disposed client.
559
+ worker.kill();
560
+ throw new DecisionBackendError('laya worker client has been disposed', undefined);
561
+ }
562
+ this.worker = worker;
563
+ return worker;
564
+ }
565
+
566
+ private spawn(): WorkerHandle {
567
+ let proc: PipeProcess;
568
+ try {
569
+ proc = this.executor.runStreaming({
570
+ command: this.pythonPath,
571
+ args: this.args,
572
+ env: this.forwardedEnv,
573
+ envMode: 'replace',
574
+ label: 'laya-worker',
575
+ cwd: this.cwd,
576
+ });
577
+ } catch (cause) {
578
+ throw new DecisionConfigError(
579
+ `Python interpreter '${this.pythonPath}' is missing or not executable; install Python 3.10+ (e.g. via 'brew install python@3.11') or set LAYA_PYTHON`,
580
+ 'LAYA_PYTHON',
581
+ { cause },
582
+ );
583
+ }
584
+ const handle = new WorkerHandle(proc, this.requestTimeoutMs, this.startupTimeoutMs);
585
+ void proc.exited.then(() => {
586
+ if (this.worker === handle) {
587
+ this.worker = null;
588
+ }
589
+ });
590
+ return handle;
591
+ }
592
+ }
@@ -0,0 +1,45 @@
1
+ # laya-mlx worker
2
+
3
+ Long-lived Python worker that executes the vendored `laya-mlx` runtime as a
4
+ JSON-lines process bridge (ADR-027). `laya_worker.py` constructs one `Agent`
5
+ and serves the line protocol for the lifetime of the process; the TypeScript
6
+ client lands in the process-bridge task that follows.
7
+
8
+ ## Running
9
+
10
+ ```sh
11
+ python3 worker/laya_worker.py [--model <hf-id>] [--model-path <dir>]
12
+ [--revision <rev>] [--dtype float16]
13
+ [--batch-size 16] [--cache-dir <dir>]
14
+ ```
15
+
16
+ Construction options may instead arrive as the first stdin line,
17
+ `{"options": {"model": …, "modelPath": …, "revision": …, "dtype": …,
18
+ "batchSize": …, "cacheDir": …}}`; a first line without an `"options"` key is
19
+ served as the first request. `--cache-dir` is exported as `HF_HUB_CACHE` before
20
+ the runtime import; `HF_TOKEN` from the environment reaches the runtime for
21
+ gated repositories.
22
+
23
+ ## Line protocol
24
+
25
+ One JSON object per line, UTF-8, LF-delimited, both directions. Every response
26
+ is flushed immediately.
27
+
28
+ | Direction | Line |
29
+ |-----------|------|
30
+ | handshake (once, first) | `{"ready": true, "model": "…", "revision": "…\|null", "maxLen": 512}` |
31
+ | startup failure (once, first) | `{"ready": false, "error": {"kind": "config", "message": "…"}}`, exit 1 |
32
+ | request | `{"id": "…", "state": "…", "questions": {"<qid>": {…}}}` |
33
+ | success | `{"id": "…", "ok": true, "result": <runtime predict payload>}` |
34
+ | failure | `{"id": "…", "ok": false, "error": {"kind": "config\|request\|backend", "message": "…"}}` |
35
+
36
+ Error kinds: `config` — construction/host problems; `request` — malformed line
37
+ or malformed question; `backend` — model problems (e.g. non-finite outputs).
38
+ The worker survives request failures; only startup failure ends the process.
39
+
40
+ The worker contains no model logic: tokenization, batching, calibration, and
41
+ answer construction all belong to the installed runtime. A request carrying
42
+ more questions than the batch size is chunked inside `Agent.predict`, and every
43
+ answer is returned keyed by its question name. The authoritative contract is
44
+ [`docs/design/laya-local-decision-backend.md`](../../../docs/design/laya-local-decision-backend.md);
45
+ supported runtime version range lives in the [package README](../README.md).