@linzumi/cli 1.0.114 → 1.0.115
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 +1 -1
- package/dist/index.js +135 -135
- package/package.json +1 -1
- package/scripts/build-worker-entry-parity-oracle.mjs +424 -0
- package/scripts/build-worker-spawn-parity-oracle.mjs +814 -0
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
// Spec: kandan/plans/2026-07-10-compute-nodes-rescript-commander-zero-ts-program.md
|
|
2
|
+
// (M3 cluster 12, worker-spawn).
|
|
3
|
+
// Relationship: Builds the TS-side PARITY ORACLE for the worker fork +
|
|
4
|
+
// lifecycle + IPC protocol of the ReScript commander port. The oracle
|
|
5
|
+
// bundles the REAL TS worker seams - the runner.ts spawn-path exports (the
|
|
6
|
+
// worker argv render + redaction, the node loader exec-argv filter, the env
|
|
7
|
+
// overlay, the ready-message parse, the ready-timeout vocabulary, the GAP A
|
|
8
|
+
// bounded-respawn orchestrator, the doomed-start preflight, the
|
|
9
|
+
// stillborn-worker vocabulary), the thread-worker IPC module (BOTH halves),
|
|
10
|
+
// the worker output routing module, the active-worker-liveness state
|
|
11
|
+
// machine, the remote codex harness worker's pure exports, and the worker
|
|
12
|
+
// argv PARSE (parseThreadCodexWorkerArgs, imported lazily with a
|
|
13
|
+
// neutralized argv[1] so the CLI entry's main-module gate can never fire) -
|
|
14
|
+
// behind the cluster 1/2 one-shot BATCH driver (a JSON array of cases on
|
|
15
|
+
// stdin, a JSON array of results on stdout).
|
|
16
|
+
//
|
|
17
|
+
// PROCESS MODE: invoked as `node <oracle> ipc-child <threadId>` the oracle
|
|
18
|
+
// becomes a REAL IPC worker child running the REAL bindThreadCodexWorkerIpc
|
|
19
|
+
// over process IPC with a scripted codex client - the cross-impl leg's TS
|
|
20
|
+
// worker (a ReScript parent drives it; a TS parent drives the ReScript
|
|
21
|
+
// child; the frames must be byte-identical either way).
|
|
22
|
+
//
|
|
23
|
+
// Determinism: IPC/microtask scheduling serializes through a bounded
|
|
24
|
+
// microtask drain after every scripted step (the cluster 10 convention);
|
|
25
|
+
// scratch paths normalize through "$DIR"; the remote join payload's
|
|
26
|
+
// process material (pid, CLI version) normalizes through placeholders.
|
|
27
|
+
//
|
|
28
|
+
// SECRETS: synthetic case payloads only; .differential/ is gitignored.
|
|
29
|
+
import { mkdir } from 'node:fs/promises';
|
|
30
|
+
import { dirname, join } from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { build } from 'esbuild';
|
|
33
|
+
|
|
34
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
35
|
+
|
|
36
|
+
const driverSource = `
|
|
37
|
+
import { mkdirSync, mkdtempSync, realpathSync } from 'node:fs';
|
|
38
|
+
import { tmpdir } from 'node:os';
|
|
39
|
+
import { dirname as pathDirname, join } from 'node:path';
|
|
40
|
+
import {
|
|
41
|
+
boundedWorkerOutputForTest,
|
|
42
|
+
isStillbornWorkerErrorForTest,
|
|
43
|
+
redactedThreadRunnerCliArgsForTest,
|
|
44
|
+
spawnThreadRunnerWithBoundedRespawnForTest,
|
|
45
|
+
stillbornWorkerFailedReasonForTest,
|
|
46
|
+
threadRunnerCliArgsForTest,
|
|
47
|
+
threadRunnerExitBeforeReadyMessageForTest,
|
|
48
|
+
threadRunnerNodeArgsForTest,
|
|
49
|
+
threadRunnerReadyMessageForTest,
|
|
50
|
+
threadRunnerReadyTimeoutErrorForTest,
|
|
51
|
+
threadRunnerReadyTimeoutMsForTest,
|
|
52
|
+
threadRunnerScriptPathForTest,
|
|
53
|
+
threadRunnerWorkerEnvOverlay,
|
|
54
|
+
threadStartTerminalFailureForTest,
|
|
55
|
+
threadWorkerPreflightFailureLineForTest,
|
|
56
|
+
threadWorkerStartPreflightFailureForTest,
|
|
57
|
+
} from './src/runner';
|
|
58
|
+
import {
|
|
59
|
+
bindThreadCodexWorkerIpc,
|
|
60
|
+
connectThreadCodexWorkerIpc,
|
|
61
|
+
isThreadCodexWorkerSessionClosedMessage,
|
|
62
|
+
} from './src/threadCodexWorkerIpc';
|
|
63
|
+
import {
|
|
64
|
+
createThreadWorkerOutputChunkHandler,
|
|
65
|
+
routeThreadWorkerOutputChunk,
|
|
66
|
+
} from './src/threadWorkerOutputRouting';
|
|
67
|
+
import {
|
|
68
|
+
createWorkerLivenessState,
|
|
69
|
+
defaultWorkerLivenessConfig,
|
|
70
|
+
recordPingSent,
|
|
71
|
+
recordPong,
|
|
72
|
+
recordProbeReply,
|
|
73
|
+
recordProbeSent,
|
|
74
|
+
shouldSendPing,
|
|
75
|
+
tickPongDeadline,
|
|
76
|
+
workerLivenessVerdict,
|
|
77
|
+
} from './src/activeWorkerLiveness';
|
|
78
|
+
import {
|
|
79
|
+
remoteCodexHarnessWorkerConfigFromEnv,
|
|
80
|
+
remoteCodexHarnessWorkerConfigFromJson,
|
|
81
|
+
remoteHarnessRunnerJoinPayload,
|
|
82
|
+
resolveLinzumiCliEntrypoint,
|
|
83
|
+
} from './src/remoteCodexHarnessWorker';
|
|
84
|
+
|
|
85
|
+
const dirPlaceholder = '$DIR';
|
|
86
|
+
|
|
87
|
+
function scratch() {
|
|
88
|
+
return realpathSync(mkdtempSync(join(tmpdir(), 'linzumi-worker-spawn-oracle-')));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function substitute(value, from, to) {
|
|
92
|
+
if (typeof value === 'string') {
|
|
93
|
+
return value.split(from).join(to);
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
return value.map((entry) => substitute(entry, from, to));
|
|
97
|
+
}
|
|
98
|
+
if (typeof value === 'object' && value !== null) {
|
|
99
|
+
return Object.fromEntries(
|
|
100
|
+
Object.entries(value).map(([key, entry]) => [
|
|
101
|
+
substitute(key, from, to),
|
|
102
|
+
substitute(entry, from, to),
|
|
103
|
+
])
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function outcome(run) {
|
|
110
|
+
try {
|
|
111
|
+
const value = run();
|
|
112
|
+
return value === undefined ? { defined: false } : { defined: true, value };
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return {
|
|
115
|
+
threw: true,
|
|
116
|
+
message: error instanceof Error ? error.message : String(error),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function drainMicrotasks() {
|
|
122
|
+
for (let tick = 0; tick < 8; tick += 1) {
|
|
123
|
+
await Promise.resolve();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- The scripted fake codex client (both the batch driver and ipc-child use it) ---
|
|
128
|
+
|
|
129
|
+
function scriptedCodex(spec, trace) {
|
|
130
|
+
const notificationCallbacks = [];
|
|
131
|
+
const requestCallbacks = [];
|
|
132
|
+
const responses = spec.responses ?? {};
|
|
133
|
+
return {
|
|
134
|
+
surface: {
|
|
135
|
+
request: (method, params) => {
|
|
136
|
+
trace.push({ t: 'codexRequest', method, params: params ?? null });
|
|
137
|
+
const scripted = responses[method];
|
|
138
|
+
if (scripted === undefined) {
|
|
139
|
+
return Promise.resolve({ id: 0, result: { echoed: method } });
|
|
140
|
+
}
|
|
141
|
+
if (scripted.reject !== undefined) {
|
|
142
|
+
return Promise.reject(new Error(scripted.reject));
|
|
143
|
+
}
|
|
144
|
+
return Promise.resolve(scripted.response);
|
|
145
|
+
},
|
|
146
|
+
notify: (method, params) => {
|
|
147
|
+
trace.push({ t: 'codexNotify', method, params: params ?? null });
|
|
148
|
+
},
|
|
149
|
+
onNotification: (callback) => {
|
|
150
|
+
notificationCallbacks.push(callback);
|
|
151
|
+
},
|
|
152
|
+
onRequest:
|
|
153
|
+
spec.withoutOnRequest === true
|
|
154
|
+
? undefined
|
|
155
|
+
: (callback) => {
|
|
156
|
+
requestCallbacks.push(callback);
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
emitNotification: (notification) => {
|
|
160
|
+
for (const callback of notificationCallbacks) {
|
|
161
|
+
callback(notification);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
emitServerRequest: (request) => {
|
|
165
|
+
const settled = [];
|
|
166
|
+
for (const callback of requestCallbacks) {
|
|
167
|
+
settled.push(
|
|
168
|
+
Promise.resolve(callback(request)).then(
|
|
169
|
+
(value) => trace.push({ t: 'serverRequestResolved', value: value ?? null }),
|
|
170
|
+
(error) =>
|
|
171
|
+
trace.push({
|
|
172
|
+
t: 'serverRequestRejected',
|
|
173
|
+
message: error instanceof Error ? error.message : String(error),
|
|
174
|
+
})
|
|
175
|
+
)
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return settled;
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- ipc-child mode: the REAL TS worker half over REAL process IPC -----------------
|
|
184
|
+
|
|
185
|
+
async function runIpcChild() {
|
|
186
|
+
const trace = [];
|
|
187
|
+
const codex = scriptedCodex(
|
|
188
|
+
{
|
|
189
|
+
responses: {
|
|
190
|
+
'thread/read': { response: { id: 7, result: { items: ['a', 'b'] } } },
|
|
191
|
+
'boom/method': { reject: 'scripted boom' },
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
trace
|
|
195
|
+
);
|
|
196
|
+
bindThreadCodexWorkerIpc(codex.surface);
|
|
197
|
+
// Scripted choreography driven by plain (non-worker-frame) IPC cues from
|
|
198
|
+
// the parent harness: emit a notification / a server request, then report
|
|
199
|
+
// the child-side trace.
|
|
200
|
+
process.on('message', (message) => {
|
|
201
|
+
if (message === null || typeof message !== 'object') {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (message.cue === 'emit_notification') {
|
|
205
|
+
codex.emitNotification(message.notification);
|
|
206
|
+
process.send?.({ cue: 'notification_emitted' });
|
|
207
|
+
} else if (message.cue === 'emit_server_request') {
|
|
208
|
+
void Promise.all(codex.emitServerRequest(message.request)).then(() => {
|
|
209
|
+
process.send?.({ cue: 'server_request_settled', trace });
|
|
210
|
+
});
|
|
211
|
+
} else if (message.cue === 'report') {
|
|
212
|
+
process.send?.({ cue: 'trace', trace });
|
|
213
|
+
} else if (message.cue === 'exit') {
|
|
214
|
+
process.exit(0);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
process.send?.({ cue: 'child_ready' });
|
|
218
|
+
// Keep the event loop alive until the parent orders exit.
|
|
219
|
+
setInterval(() => {}, 60_000);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- Batch ops ----------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
function handleIpcWorker(testCase) {
|
|
225
|
+
const trace = [];
|
|
226
|
+
const sent = [];
|
|
227
|
+
const codex = scriptedCodex(testCase.codex ?? {}, trace);
|
|
228
|
+
let connected = true;
|
|
229
|
+
let sendMode = 'ok';
|
|
230
|
+
const ipc = {
|
|
231
|
+
send: (message) => {
|
|
232
|
+
if (sendMode === 'throw') {
|
|
233
|
+
throw new Error(testCase.sendThrowMessage ?? 'scripted send failure');
|
|
234
|
+
}
|
|
235
|
+
sent.push(message);
|
|
236
|
+
return true;
|
|
237
|
+
},
|
|
238
|
+
get connected() {
|
|
239
|
+
return connected;
|
|
240
|
+
},
|
|
241
|
+
on: (event, callback) => {
|
|
242
|
+
if (event === 'message') {
|
|
243
|
+
ipcMessageHandlers.push(callback);
|
|
244
|
+
}
|
|
245
|
+
return ipc;
|
|
246
|
+
},
|
|
247
|
+
once: (event, callback) => {
|
|
248
|
+
if (event === 'disconnect') {
|
|
249
|
+
disconnectHandlers.push(callback);
|
|
250
|
+
}
|
|
251
|
+
return ipc;
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
const ipcMessageHandlers = [];
|
|
255
|
+
const disconnectHandlers = [];
|
|
256
|
+
|
|
257
|
+
const run = async () => {
|
|
258
|
+
bindThreadCodexWorkerIpc(codex.surface, ipc);
|
|
259
|
+
for (const step of testCase.script ?? []) {
|
|
260
|
+
if (step.step === 'parentMessage') {
|
|
261
|
+
for (const handler of ipcMessageHandlers) {
|
|
262
|
+
handler(step.message);
|
|
263
|
+
}
|
|
264
|
+
} else if (step.step === 'notification') {
|
|
265
|
+
codex.emitNotification(step.notification);
|
|
266
|
+
} else if (step.step === 'serverRequest') {
|
|
267
|
+
codex.emitServerRequest(step.request);
|
|
268
|
+
} else if (step.step === 'disconnectChannel') {
|
|
269
|
+
connected = false;
|
|
270
|
+
} else if (step.step === 'sendMode') {
|
|
271
|
+
sendMode = step.mode;
|
|
272
|
+
} else if (step.step === 'disconnectEvent') {
|
|
273
|
+
for (const handler of disconnectHandlers.splice(0)) {
|
|
274
|
+
handler();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
await drainMicrotasks();
|
|
278
|
+
}
|
|
279
|
+
return { sent, trace };
|
|
280
|
+
};
|
|
281
|
+
return run().catch((error) => ({
|
|
282
|
+
threw: true,
|
|
283
|
+
message: error instanceof Error ? error.message : String(error),
|
|
284
|
+
sent,
|
|
285
|
+
trace,
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function handleIpcParent(testCase) {
|
|
290
|
+
const trace = [];
|
|
291
|
+
const sent = [];
|
|
292
|
+
let connected = true;
|
|
293
|
+
let sendMode = 'ok';
|
|
294
|
+
const messageHandlers = [];
|
|
295
|
+
const exitHandlers = [];
|
|
296
|
+
const disconnectHandlers = [];
|
|
297
|
+
const child = {
|
|
298
|
+
send: (message) => {
|
|
299
|
+
if (sendMode === 'throw') {
|
|
300
|
+
throw new Error(testCase.sendThrowMessage ?? 'scripted send failure');
|
|
301
|
+
}
|
|
302
|
+
sent.push(message);
|
|
303
|
+
return true;
|
|
304
|
+
},
|
|
305
|
+
get connected() {
|
|
306
|
+
return connected;
|
|
307
|
+
},
|
|
308
|
+
on: (event, callback) => {
|
|
309
|
+
if (event === 'message') messageHandlers.push(callback);
|
|
310
|
+
return child;
|
|
311
|
+
},
|
|
312
|
+
once: (event, callback) => {
|
|
313
|
+
if (event === 'exit') exitHandlers.push(callback);
|
|
314
|
+
if (event === 'disconnect') disconnectHandlers.push(callback);
|
|
315
|
+
return child;
|
|
316
|
+
},
|
|
317
|
+
off: (event, callback) => {
|
|
318
|
+
const pool =
|
|
319
|
+
event === 'message'
|
|
320
|
+
? messageHandlers
|
|
321
|
+
: event === 'exit'
|
|
322
|
+
? exitHandlers
|
|
323
|
+
: disconnectHandlers;
|
|
324
|
+
const index = pool.indexOf(callback);
|
|
325
|
+
if (index >= 0) pool.splice(index, 1);
|
|
326
|
+
return child;
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const client = connectThreadCodexWorkerIpc(child);
|
|
331
|
+
client.onNotification((notification) => trace.push({ t: 'notification', notification }));
|
|
332
|
+
if (testCase.requestHandlers !== undefined) {
|
|
333
|
+
for (const handler of testCase.requestHandlers) {
|
|
334
|
+
client.onRequest((request) => {
|
|
335
|
+
trace.push({ t: 'serverRequest', handler: handler.name, request });
|
|
336
|
+
if (handler.kind === 'answer') return handler.result;
|
|
337
|
+
if (handler.kind === 'reject') return Promise.reject(new Error(handler.message));
|
|
338
|
+
return undefined;
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (const step of testCase.script ?? []) {
|
|
344
|
+
if (step.step === 'request') {
|
|
345
|
+
void client.request(step.method, step.params ?? undefined).then(
|
|
346
|
+
(response) => trace.push({ t: 'requestResolved', response }),
|
|
347
|
+
(error) =>
|
|
348
|
+
trace.push({
|
|
349
|
+
t: 'requestRejected',
|
|
350
|
+
message: error instanceof Error ? error.message : String(error),
|
|
351
|
+
})
|
|
352
|
+
);
|
|
353
|
+
} else if (step.step === 'notify') {
|
|
354
|
+
try {
|
|
355
|
+
client.notify(step.method, step.params ?? undefined);
|
|
356
|
+
trace.push({ t: 'notifyOk' });
|
|
357
|
+
} catch (error) {
|
|
358
|
+
trace.push({
|
|
359
|
+
t: 'notifyThrew',
|
|
360
|
+
message: error instanceof Error ? error.message : String(error),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
} else if (step.step === 'childMessage') {
|
|
364
|
+
for (const handler of [...messageHandlers]) {
|
|
365
|
+
handler(step.message);
|
|
366
|
+
}
|
|
367
|
+
} else if (step.step === 'lateNotificationSubscribe') {
|
|
368
|
+
client.onNotification((notification) =>
|
|
369
|
+
trace.push({ t: 'lateNotification', notification })
|
|
370
|
+
);
|
|
371
|
+
} else if (step.step === 'exitEvent') {
|
|
372
|
+
for (const handler of exitHandlers.splice(0)) handler();
|
|
373
|
+
} else if (step.step === 'disconnectEvent') {
|
|
374
|
+
for (const handler of disconnectHandlers.splice(0)) handler();
|
|
375
|
+
} else if (step.step === 'disconnectChannel') {
|
|
376
|
+
connected = false;
|
|
377
|
+
} else if (step.step === 'sendMode') {
|
|
378
|
+
sendMode = step.mode;
|
|
379
|
+
} else if (step.step === 'isClosed') {
|
|
380
|
+
trace.push({ t: 'isClosed', value: client.isClosed?.() ?? null });
|
|
381
|
+
} else if (step.step === 'close') {
|
|
382
|
+
client.close();
|
|
383
|
+
trace.push({ t: 'closed' });
|
|
384
|
+
}
|
|
385
|
+
await drainMicrotasks();
|
|
386
|
+
}
|
|
387
|
+
return { sent, trace };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function handleRespawn(testCase) {
|
|
391
|
+
const script = [...(testCase.script ?? [])];
|
|
392
|
+
const trace = [];
|
|
393
|
+
const attempt = (attemptNumber) => {
|
|
394
|
+
const step = script.shift() ?? { kind: 'ready' };
|
|
395
|
+
trace.push({ t: 'attempt', attemptNumber, kind: step.kind });
|
|
396
|
+
if (step.kind === 'ready') {
|
|
397
|
+
return Promise.resolve({ kind: 'ready', handle: { marker: 'handle-' + attemptNumber } });
|
|
398
|
+
}
|
|
399
|
+
return Promise.resolve({
|
|
400
|
+
kind: 'exited-before-ready',
|
|
401
|
+
code: step.code ?? null,
|
|
402
|
+
signal: step.signal ?? null,
|
|
403
|
+
capturedOutput: step.capturedOutput ?? '',
|
|
404
|
+
});
|
|
405
|
+
};
|
|
406
|
+
try {
|
|
407
|
+
const handle = await spawnThreadRunnerWithBoundedRespawnForTest(attempt, {
|
|
408
|
+
maxAttempts: testCase.maxAttempts,
|
|
409
|
+
backoffMs: 0,
|
|
410
|
+
sleep: () => Promise.resolve(),
|
|
411
|
+
onRespawn: (info) =>
|
|
412
|
+
trace.push({ t: 'respawn', attemptNumber: info.attemptNumber }),
|
|
413
|
+
});
|
|
414
|
+
return { trace, handle };
|
|
415
|
+
} catch (error) {
|
|
416
|
+
return {
|
|
417
|
+
trace,
|
|
418
|
+
threw: error instanceof Error ? error.message : String(error),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function handleOutputRouting(testCase) {
|
|
424
|
+
const trace = [];
|
|
425
|
+
let captured = '';
|
|
426
|
+
const capture = { active: true };
|
|
427
|
+
let tui = false;
|
|
428
|
+
const handler = createThreadWorkerOutputChunkHandler({
|
|
429
|
+
tuiActive: () => tui,
|
|
430
|
+
terminal: { write: (chunk) => trace.push({ t: 'terminal', chunk: String(chunk) }) },
|
|
431
|
+
writeAuditEvent: (event, data) => trace.push({ t: 'audit', event, data }),
|
|
432
|
+
pid: testCase.pid ?? undefined,
|
|
433
|
+
streamName: testCase.streamName ?? 'stderr',
|
|
434
|
+
capture,
|
|
435
|
+
appendCapturedOutput: (text) => {
|
|
436
|
+
captured += text;
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
for (const step of testCase.script ?? []) {
|
|
440
|
+
if (step.step === 'chunk') {
|
|
441
|
+
tui = step.tuiActive === true;
|
|
442
|
+
capture.active = step.captureActive === true;
|
|
443
|
+
handler(step.text);
|
|
444
|
+
} else if (step.step === 'route') {
|
|
445
|
+
trace.push({
|
|
446
|
+
t: 'route',
|
|
447
|
+
route: routeThreadWorkerOutputChunk({
|
|
448
|
+
tuiActive: step.tuiActive === true,
|
|
449
|
+
captureActive: step.captureActive === true,
|
|
450
|
+
}),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return { trace, captured };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function handleLiveness(testCase) {
|
|
458
|
+
const config = testCase.config ?? defaultWorkerLivenessConfig;
|
|
459
|
+
let state;
|
|
460
|
+
const trace = [];
|
|
461
|
+
for (const step of testCase.schedule ?? []) {
|
|
462
|
+
switch (step.op) {
|
|
463
|
+
case 'create':
|
|
464
|
+
state = createWorkerLivenessState(step.now);
|
|
465
|
+
break;
|
|
466
|
+
case 'pingSent':
|
|
467
|
+
recordPingSent(state, step.now);
|
|
468
|
+
break;
|
|
469
|
+
case 'pong':
|
|
470
|
+
recordPong(state, step.now);
|
|
471
|
+
break;
|
|
472
|
+
case 'probeSent':
|
|
473
|
+
recordProbeSent(state, step.now);
|
|
474
|
+
break;
|
|
475
|
+
case 'probeReply':
|
|
476
|
+
recordProbeReply(state, step.now);
|
|
477
|
+
break;
|
|
478
|
+
case 'tick':
|
|
479
|
+
tickPongDeadline(state, config, step.now);
|
|
480
|
+
break;
|
|
481
|
+
case 'verdict':
|
|
482
|
+
trace.push({ t: 'verdict', now: step.now, value: workerLivenessVerdict(state, config, step.now) });
|
|
483
|
+
break;
|
|
484
|
+
case 'shouldSendPing':
|
|
485
|
+
trace.push({ t: 'shouldSendPing', now: step.now, value: shouldSendPing(state, config, step.now) });
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return { trace };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function handlePreflight(testCase) {
|
|
493
|
+
const dir = scratch();
|
|
494
|
+
const sub = (value) => substitute(value, dirPlaceholder, dir);
|
|
495
|
+
for (const relPath of testCase.mkdirs ?? []) {
|
|
496
|
+
mkdirSync(join(dir, relPath), { recursive: true });
|
|
497
|
+
}
|
|
498
|
+
const result = outcome(() =>
|
|
499
|
+
threadWorkerStartPreflightFailureForTest({
|
|
500
|
+
cwd: sub(testCase.cwd),
|
|
501
|
+
allowedCwds: sub(testCase.allowedCwds ?? []),
|
|
502
|
+
})
|
|
503
|
+
);
|
|
504
|
+
return substitute(result, dir, dirPlaceholder);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const savedEnv = {};
|
|
508
|
+
|
|
509
|
+
function setCaseEnv(env) {
|
|
510
|
+
for (const [key, value] of Object.entries(env ?? {})) {
|
|
511
|
+
savedEnv[key] = process.env[key];
|
|
512
|
+
if (value === null) {
|
|
513
|
+
delete process.env[key];
|
|
514
|
+
} else {
|
|
515
|
+
process.env[key] = value;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function restoreCaseEnv() {
|
|
521
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
522
|
+
if (value === undefined) {
|
|
523
|
+
delete process.env[key];
|
|
524
|
+
} else {
|
|
525
|
+
process.env[key] = value;
|
|
526
|
+
}
|
|
527
|
+
delete savedEnv[key];
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
let indexModulePromise;
|
|
532
|
+
|
|
533
|
+
// parseThreadCodexWorkerArgs lives in the CLI entry module, whose
|
|
534
|
+
// main-module gate compares import.meta.url with argv[1]; neutralize
|
|
535
|
+
// argv[1] BEFORE the lazy import so the gate can never fire inside the
|
|
536
|
+
// oracle.
|
|
537
|
+
function loadIndexModule() {
|
|
538
|
+
if (indexModulePromise === undefined) {
|
|
539
|
+
process.argv[1] = '/nonexistent/linzumi-worker-spawn-oracle-argv1';
|
|
540
|
+
indexModulePromise = import('./src/index');
|
|
541
|
+
}
|
|
542
|
+
return indexModulePromise;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function handleParseWorkerArgs(testCase) {
|
|
546
|
+
const dir = scratch();
|
|
547
|
+
const sub = (value) => substitute(value, dirPlaceholder, dir);
|
|
548
|
+
for (const relPath of testCase.mkdirs ?? []) {
|
|
549
|
+
mkdirSync(join(dir, relPath), { recursive: true });
|
|
550
|
+
}
|
|
551
|
+
const { parseThreadCodexWorkerArgs } = await loadIndexModule();
|
|
552
|
+
setCaseEnv(sub(testCase.env ?? {}));
|
|
553
|
+
try {
|
|
554
|
+
const options = parseThreadCodexWorkerArgs(sub(testCase.args ?? []));
|
|
555
|
+
// Project the worker-relevant slice (the codec's contract surface).
|
|
556
|
+
return substitute(
|
|
557
|
+
{
|
|
558
|
+
value: {
|
|
559
|
+
kandanUrl: options.kandanUrl,
|
|
560
|
+
token: options.token,
|
|
561
|
+
runnerId: options.runnerId,
|
|
562
|
+
workspaceSlug: options.workspaceSlug ?? null,
|
|
563
|
+
cwd: options.cwd,
|
|
564
|
+
codexBin: options.codexBin,
|
|
565
|
+
vmExecution: options.vmExecution === true,
|
|
566
|
+
executionMode: options.executionMode ?? null,
|
|
567
|
+
egressMode: options.egressMode ?? null,
|
|
568
|
+
projectPublicId: options.projectPublicId ?? null,
|
|
569
|
+
goldenImagePublicId: options.goldenImagePublicId ?? null,
|
|
570
|
+
fast: options.fast === true,
|
|
571
|
+
logFile: options.logFile ?? null,
|
|
572
|
+
allowedCwds: options.allowedCwds,
|
|
573
|
+
missingAllowedCwds: options.missingAllowedCwds ?? [],
|
|
574
|
+
allowedForwardPorts: options.allowedForwardPorts ?? [],
|
|
575
|
+
runtimeDefaults: options.runtimeDefaults ?? null,
|
|
576
|
+
codexModelProvider: options.codexModelProvider ?? null,
|
|
577
|
+
linzumiAgentMcpToken: options.linzumiAgentMcpToken ?? null,
|
|
578
|
+
kandanThreadId: options.threadProcess?.kandanThreadId ?? null,
|
|
579
|
+
debugTrace: options.debugTrace ?? null,
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
dir,
|
|
583
|
+
dirPlaceholder
|
|
584
|
+
);
|
|
585
|
+
} catch (error) {
|
|
586
|
+
return substitute(
|
|
587
|
+
{ threw: error instanceof Error ? error.message : String(error) },
|
|
588
|
+
dir,
|
|
589
|
+
dirPlaceholder
|
|
590
|
+
);
|
|
591
|
+
} finally {
|
|
592
|
+
restoreCaseEnv();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function handle(testCase) {
|
|
597
|
+
switch (testCase.op) {
|
|
598
|
+
case 'cliArgs':
|
|
599
|
+
return outcome(() => threadRunnerCliArgsForTest(testCase.options));
|
|
600
|
+
case 'redactedCliArgs':
|
|
601
|
+
return outcome(() => redactedThreadRunnerCliArgsForTest(testCase.args));
|
|
602
|
+
case 'envOverlay':
|
|
603
|
+
return outcome(() =>
|
|
604
|
+
threadRunnerWorkerEnvOverlay(testCase.options, {
|
|
605
|
+
devServicesParentPid: testCase.devServicesParentPid,
|
|
606
|
+
forwardableOpenAiApiKey: testCase.forwardableOpenAiApiKey ?? undefined,
|
|
607
|
+
})
|
|
608
|
+
);
|
|
609
|
+
case 'nodeArgs':
|
|
610
|
+
return outcome(() =>
|
|
611
|
+
threadRunnerNodeArgsForTest(testCase.scriptPath, testCase.args, testCase.execArgv)
|
|
612
|
+
);
|
|
613
|
+
case 'scriptPath':
|
|
614
|
+
return outcome(() =>
|
|
615
|
+
threadRunnerScriptPathForTest(testCase.scriptPath ?? undefined, testCase.cwd)
|
|
616
|
+
);
|
|
617
|
+
case 'readyMessage':
|
|
618
|
+
return outcome(() =>
|
|
619
|
+
threadRunnerReadyMessageForTest(testCase.message, testCase.expectedKandanThreadId)
|
|
620
|
+
);
|
|
621
|
+
case 'sessionClosedGuard':
|
|
622
|
+
return outcome(() => isThreadCodexWorkerSessionClosedMessage(testCase.message));
|
|
623
|
+
case 'readyTimeoutMs':
|
|
624
|
+
return outcome(() =>
|
|
625
|
+
threadRunnerReadyTimeoutMsForTest({
|
|
626
|
+
threadRunnerReadyTimeoutMs: testCase.configured ?? undefined,
|
|
627
|
+
})
|
|
628
|
+
);
|
|
629
|
+
case 'readyTimeoutError':
|
|
630
|
+
return outcome(
|
|
631
|
+
() =>
|
|
632
|
+
threadRunnerReadyTimeoutErrorForTest(testCase.kandanThreadId, testCase.timeoutMs).message
|
|
633
|
+
);
|
|
634
|
+
case 'boundedOutput':
|
|
635
|
+
return outcome(() => boundedWorkerOutputForTest(testCase.value));
|
|
636
|
+
case 'exitBeforeReadyMessage':
|
|
637
|
+
return outcome(() =>
|
|
638
|
+
threadRunnerExitBeforeReadyMessageForTest({
|
|
639
|
+
kind: 'exited-before-ready',
|
|
640
|
+
code: testCase.code ?? null,
|
|
641
|
+
signal: testCase.signal ?? null,
|
|
642
|
+
capturedOutput: testCase.capturedOutput ?? '',
|
|
643
|
+
})
|
|
644
|
+
);
|
|
645
|
+
case 'preflight':
|
|
646
|
+
return handlePreflight(testCase);
|
|
647
|
+
case 'preflightLine':
|
|
648
|
+
return outcome(() => threadWorkerPreflightFailureLineForTest(testCase.capturedOutput));
|
|
649
|
+
case 'stillborn':
|
|
650
|
+
return outcome(() => ({
|
|
651
|
+
isStillborn: isStillbornWorkerErrorForTest(new Error(testCase.message)),
|
|
652
|
+
failedReason: stillbornWorkerFailedReasonForTest(new Error(testCase.message)),
|
|
653
|
+
terminal: threadStartTerminalFailureForTest(new Error(testCase.message)),
|
|
654
|
+
}));
|
|
655
|
+
case 'respawn':
|
|
656
|
+
return handleRespawn(testCase);
|
|
657
|
+
case 'outputRouting':
|
|
658
|
+
return handleOutputRouting(testCase);
|
|
659
|
+
case 'liveness':
|
|
660
|
+
return handleLiveness(testCase);
|
|
661
|
+
case 'ipcWorker':
|
|
662
|
+
return handleIpcWorker(testCase);
|
|
663
|
+
case 'ipcParent':
|
|
664
|
+
return handleIpcParent(testCase);
|
|
665
|
+
case 'remoteConfig':
|
|
666
|
+
return outcome(() => remoteCodexHarnessWorkerConfigFromJson(testCase.json));
|
|
667
|
+
case 'remoteConfigEnv':
|
|
668
|
+
return outcome(() => remoteCodexHarnessWorkerConfigFromEnv(testCase.env ?? {}));
|
|
669
|
+
case 'remoteEntrypoint':
|
|
670
|
+
return outcome(() => resolveLinzumiCliEntrypoint(testCase.entrypoint ?? undefined));
|
|
671
|
+
case 'remoteJoinPayload':
|
|
672
|
+
return outcome(() => {
|
|
673
|
+
const payload = remoteHarnessRunnerJoinPayload(testCase.config);
|
|
674
|
+
payload.runnerPid = 0;
|
|
675
|
+
payload.version = '<version>';
|
|
676
|
+
return payload;
|
|
677
|
+
});
|
|
678
|
+
case 'parseWorkerArgs':
|
|
679
|
+
return handleParseWorkerArgs(testCase);
|
|
680
|
+
default:
|
|
681
|
+
return { threw: true, message: 'unknown op ' + String(testCase.op) };
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// --- ipc-parent mode: the REAL TS parent half over REAL process IPC ------------------
|
|
686
|
+
// "node <oracle> ipc-parent <childEntry> [childArg...]" spawns the child
|
|
687
|
+
// with an IPC channel, drives the REAL connectThreadCodexWorkerIpc through
|
|
688
|
+
// the fixed cross-impl choreography, and prints one JSON result: the raw
|
|
689
|
+
// frames the child sent (cue messages excluded), the parent-observed
|
|
690
|
+
// events, and the child's own trace. Run against the TS ipc-child AND the
|
|
691
|
+
// ReScript child probe, the outputs must be byte-identical - the same
|
|
692
|
+
// commander parent drives both workers identically.
|
|
693
|
+
async function runIpcParent() {
|
|
694
|
+
const { spawn } = await import('node:child_process');
|
|
695
|
+
const childArgs = process.argv.slice(3);
|
|
696
|
+
const child = spawn(process.execPath, childArgs, {
|
|
697
|
+
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
|
698
|
+
});
|
|
699
|
+
const frames = [];
|
|
700
|
+
const parentEvents = [];
|
|
701
|
+
let childTrace = null;
|
|
702
|
+
const cueWaiters = new Map();
|
|
703
|
+
const waitForCue = (cue) =>
|
|
704
|
+
new Promise((resolve) => {
|
|
705
|
+
cueWaiters.set(cue, resolve);
|
|
706
|
+
});
|
|
707
|
+
child.on('message', (message) => {
|
|
708
|
+
if (message !== null && typeof message === 'object' && message.cue !== undefined) {
|
|
709
|
+
const waiter = cueWaiters.get(message.cue);
|
|
710
|
+
if (waiter !== undefined) {
|
|
711
|
+
cueWaiters.delete(message.cue);
|
|
712
|
+
waiter(message);
|
|
713
|
+
}
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
frames.push(message);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
await waitForCue('child_ready');
|
|
720
|
+
const client = connectThreadCodexWorkerIpc(child);
|
|
721
|
+
client.onNotification((notification) =>
|
|
722
|
+
parentEvents.push({ t: 'notification', notification })
|
|
723
|
+
);
|
|
724
|
+
client.onRequest((request) => {
|
|
725
|
+
parentEvents.push({ t: 'serverRequest', request });
|
|
726
|
+
return { decision: 'approved' };
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
const readResponse = await client.request('thread/read', { threadId: 't-cross' });
|
|
731
|
+
parentEvents.push({ t: 'requestResolved', response: readResponse });
|
|
732
|
+
} catch (error) {
|
|
733
|
+
parentEvents.push({
|
|
734
|
+
t: 'requestRejected',
|
|
735
|
+
message: error instanceof Error ? error.message : String(error),
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
await client.request('boom/method');
|
|
740
|
+
parentEvents.push({ t: 'requestResolved', response: null });
|
|
741
|
+
} catch (error) {
|
|
742
|
+
parentEvents.push({
|
|
743
|
+
t: 'requestRejected',
|
|
744
|
+
message: error instanceof Error ? error.message : String(error),
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
client.notify('turn/interrupt', { reason: 'cross-impl' });
|
|
748
|
+
const notified = waitForCue('notification_emitted');
|
|
749
|
+
child.send({
|
|
750
|
+
cue: 'emit_notification',
|
|
751
|
+
notification: { method: 'thread/event', params: { kind: 'delta', n: 1 } },
|
|
752
|
+
});
|
|
753
|
+
await notified;
|
|
754
|
+
const settled = waitForCue('server_request_settled');
|
|
755
|
+
child.send({
|
|
756
|
+
cue: 'emit_server_request',
|
|
757
|
+
request: { id: 21, method: 'applyPatchApproval', params: { call: 'c-cross' } },
|
|
758
|
+
});
|
|
759
|
+
await settled;
|
|
760
|
+
const reported = waitForCue('trace');
|
|
761
|
+
child.send({ cue: 'report' });
|
|
762
|
+
childTrace = (await reported).trace;
|
|
763
|
+
child.send({ cue: 'exit' });
|
|
764
|
+
await new Promise((resolve) => child.once('exit', resolve));
|
|
765
|
+
process.stdout.write(JSON.stringify({ frames, parentEvents, childTrace }));
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (process.argv[2] === 'ipc-child') {
|
|
769
|
+
await runIpcChild();
|
|
770
|
+
} else if (process.argv[2] === 'ipc-parent') {
|
|
771
|
+
await runIpcParent();
|
|
772
|
+
} else {
|
|
773
|
+
let stdin = '';
|
|
774
|
+
process.stdin.setEncoding('utf8');
|
|
775
|
+
for await (const chunk of process.stdin) {
|
|
776
|
+
stdin += chunk;
|
|
777
|
+
}
|
|
778
|
+
const cases = JSON.parse(stdin);
|
|
779
|
+
const results = [];
|
|
780
|
+
for (const testCase of cases) {
|
|
781
|
+
results.push(await handle(testCase));
|
|
782
|
+
}
|
|
783
|
+
process.stdout.write(JSON.stringify(results));
|
|
784
|
+
}
|
|
785
|
+
`;
|
|
786
|
+
|
|
787
|
+
await mkdir(join(packageRoot, '.differential'), { recursive: true });
|
|
788
|
+
|
|
789
|
+
// runner.ts's graph resolves assets/ relative to the bundle at import time
|
|
790
|
+
// (same requirement as build-differential-entry.mjs).
|
|
791
|
+
await mkdir(join(packageRoot, '.differential/assets'), { recursive: true });
|
|
792
|
+
const { copyFile } = await import('node:fs/promises');
|
|
793
|
+
await copyFile(
|
|
794
|
+
join(packageRoot, 'src/assets/linzumi-logo.svg'),
|
|
795
|
+
join(packageRoot, '.differential/assets/linzumi-logo.svg')
|
|
796
|
+
);
|
|
797
|
+
|
|
798
|
+
await build({
|
|
799
|
+
stdin: {
|
|
800
|
+
contents: driverSource,
|
|
801
|
+
resolveDir: packageRoot,
|
|
802
|
+
sourcefile: 'worker-spawn-parity-driver.ts',
|
|
803
|
+
loader: 'ts',
|
|
804
|
+
},
|
|
805
|
+
bundle: true,
|
|
806
|
+
platform: 'node',
|
|
807
|
+
target: 'node20',
|
|
808
|
+
format: 'esm',
|
|
809
|
+
outfile: join(packageRoot, '.differential/worker-spawn-parity-oracle.mjs'),
|
|
810
|
+
minify: false,
|
|
811
|
+
sourcemap: false,
|
|
812
|
+
legalComments: 'none',
|
|
813
|
+
external: ['ws', 'undici', 'blessed', '@anthropic-ai/claude-agent-sdk'],
|
|
814
|
+
});
|