@linzumi/cli 1.0.111 → 1.0.113

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,455 @@
1
+ // Spec: kandan/plans/2026-07-10-compute-nodes-rescript-commander-zero-ts-program.md
2
+ // (M3 cluster 10, runner core - the workspace lock + operator takeover flow).
3
+ // Relationship: Builds the TS-side PARITY ORACLE for the commander
4
+ // workspace-lock port, the sibling of build-config-identity-parity-oracle.mjs
5
+ // (cluster 2). The oracle bundles the REAL runnerLock.ts +
6
+ // runnerLockTakeover.ts behind a one-shot batch driver: a JSON array of cases
7
+ // on stdin, a JSON array of results on stdout. The ReScript package's
8
+ // CoreLockParityConformance drives the SAME inputs through
9
+ // src/core/CommanderLock.res / CommanderLockTakeover.res in its own scratch
10
+ // directories and asserts accept/reject/value equivalence, pinned contract
11
+ // error strings, log/prompt/signal trace parity AND byte-identical lock-file
12
+ // trees - the cross-impl contract that lets both commanders share the
13
+ // ~/.linzumi/runners locks through M4.
14
+ //
15
+ // Determinism: every source of nondeterminism is pinned per case - the clock
16
+ // is a scripted per-case cell read through the injected `now`, pid liveness
17
+ // is a scripted set, SIGKILL/SIGTERM outcomes are scripted (per-pid /
18
+ // per-signal failure codes), prompts are scripted answer queues, and the
19
+ // takeover waits run on a virtual clock advanced by the injected `sleep`.
20
+ // Scratch paths are normalized through the "$DIR" placeholder in both
21
+ // directions.
22
+ //
23
+ // Like the sibling oracles, the output is a local test artifact
24
+ // (.differential/ is gitignored, never published) and stays unminified for
25
+ // debuggability.
26
+ import { mkdir } from 'node:fs/promises';
27
+ import { dirname, join } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { build } from 'esbuild';
30
+
31
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
32
+
33
+ const driverSource = `
34
+ import {
35
+ mkdtempSync,
36
+ mkdirSync,
37
+ readdirSync,
38
+ readFileSync,
39
+ realpathSync,
40
+ statSync,
41
+ unlinkSync,
42
+ writeFileSync,
43
+ } from 'node:fs';
44
+ import { tmpdir } from 'node:os';
45
+ import { join, relative } from 'node:path';
46
+ import {
47
+ acquireIncomingGenerationRunnerLock,
48
+ acquireRunnerLock,
49
+ assessRunnerLockHolder,
50
+ compareCliVersions,
51
+ electronAutoConnectLaunchSource,
52
+ incomingGenerationRunnerLockPath,
53
+ readRunnerLockOwner,
54
+ RunnerLockHeldError,
55
+ runnerLockHeartbeatIntervalMs,
56
+ runnerLockHeldErrorName,
57
+ runnerLockPath,
58
+ runnerLockTakeoverAfterMs,
59
+ shouldAutoTakeOverForUpdate,
60
+ } from './src/runnerLock';
61
+ import {
62
+ isRunnerLockConflictReportedError,
63
+ resolveRunnerLockConflict,
64
+ RunnerLockConflictReportedError,
65
+ runnerLockConflictReport,
66
+ runnerLockConflictReportedErrorName,
67
+ runnerLockTakeoverForcefulStopMs,
68
+ runnerLockTakeoverGracefulStopMs,
69
+ runnerLockTakeoverPollMs,
70
+ runnerLockTakeoverSleep,
71
+ shouldResolveRunnerLockConflict,
72
+ } from './src/runnerLockTakeover';
73
+
74
+ // --- Placeholder + snapshot plumbing --------------------------------------------
75
+
76
+ const dirPlaceholder = '$DIR';
77
+
78
+ function scratch() {
79
+ // realpath so macOS /var -> /private/var never splits the two impls'
80
+ // placeholder substitution.
81
+ return realpathSync(mkdtempSync(join(tmpdir(), 'linzumi-core-lock-oracle-')));
82
+ }
83
+
84
+ function substitute(value, from, to) {
85
+ if (typeof value === 'string') {
86
+ return value.split(from).join(to);
87
+ }
88
+ if (Array.isArray(value)) {
89
+ return value.map((entry) => substitute(entry, from, to));
90
+ }
91
+ if (typeof value === 'object' && value !== null) {
92
+ return Object.fromEntries(
93
+ Object.entries(value).map(([key, entry]) => [
94
+ substitute(key, from, to),
95
+ substitute(entry, from, to),
96
+ ])
97
+ );
98
+ }
99
+ return value;
100
+ }
101
+
102
+ function snapshotFiles(dir) {
103
+ const files = {};
104
+ const walk = (current) => {
105
+ for (const name of readdirSync(current).sort()) {
106
+ const full = join(current, name);
107
+ if (statSync(full).isDirectory()) {
108
+ walk(full);
109
+ } else {
110
+ files[relative(dir, full)] = readFileSync(full, 'utf8');
111
+ }
112
+ }
113
+ };
114
+ walk(dir);
115
+ return files;
116
+ }
117
+
118
+ function outcome(run) {
119
+ try {
120
+ const value = run();
121
+ return value === undefined ? { defined: false } : { defined: true, value };
122
+ } catch (error) {
123
+ return {
124
+ threw: true,
125
+ name: error instanceof Error ? error.name : 'Error',
126
+ message: error instanceof Error ? error.message : String(error),
127
+ };
128
+ }
129
+ }
130
+
131
+ // --- Pure surfaces ----------------------------------------------------------------
132
+
133
+ function handleLockPath(testCase) {
134
+ const dir = scratch();
135
+ const configPath = join(dir, 'config.json');
136
+ const result = outcome(() =>
137
+ runnerLockPath(testCase.machineId, configPath, testCase.linzumiUrl)
138
+ );
139
+ return substitute(result, dir, dirPlaceholder);
140
+ }
141
+
142
+ function handleAssessHolder(testCase) {
143
+ const alive = new Set(testCase.alivePids ?? []);
144
+ return outcome(() =>
145
+ assessRunnerLockHolder(
146
+ testCase.record,
147
+ (pid) => alive.has(pid),
148
+ () => new Date(testCase.nowMs)
149
+ )
150
+ );
151
+ }
152
+
153
+ function handleShouldAutoTakeOver(testCase) {
154
+ return outcome(() =>
155
+ shouldAutoTakeOverForUpdate(
156
+ testCase.existing,
157
+ testCase.myVersion,
158
+ testCase.myLaunchSource,
159
+ testCase.holderState
160
+ )
161
+ );
162
+ }
163
+
164
+ function handleHeldMessage(testCase) {
165
+ return outcome(() => {
166
+ const error = new RunnerLockHeldError(testCase.lockPath, testCase.record);
167
+ return { name: error.name, message: error.message };
168
+ });
169
+ }
170
+
171
+ async function handleConstants() {
172
+ await runnerLockTakeoverSleep(0);
173
+ const reported = new RunnerLockConflictReportedError();
174
+ return {
175
+ heartbeatIntervalMs: runnerLockHeartbeatIntervalMs,
176
+ takeoverAfterMs: runnerLockTakeoverAfterMs,
177
+ heldErrorName: runnerLockHeldErrorName,
178
+ electronAutoConnectLaunchSource,
179
+ gracefulStopMs: runnerLockTakeoverGracefulStopMs,
180
+ forcefulStopMs: runnerLockTakeoverForcefulStopMs,
181
+ pollMs: runnerLockTakeoverPollMs,
182
+ conflictReportedErrorName: runnerLockConflictReportedErrorName,
183
+ conflictReportedError: {
184
+ name: reported.name,
185
+ message: reported.message,
186
+ guard: isRunnerLockConflictReportedError(reported),
187
+ },
188
+ sleepResolved: true,
189
+ };
190
+ }
191
+
192
+ // --- Lock lifecycle ------------------------------------------------------------------
193
+
194
+ function handleLockLifecycle(testCase) {
195
+ const dir = scratch();
196
+ const configPath = join(dir, 'config.json');
197
+ const actions = substitute(testCase.actions ?? [], dirPlaceholder, dir);
198
+ const killFailures = testCase.killFailures ?? {};
199
+ let clockMs = 0;
200
+ const alive = new Set();
201
+ const events = [];
202
+ const handles = new Map();
203
+ const now = () => new Date(clockMs);
204
+ const isPidAlive = (pid) => alive.has(pid);
205
+ const killPid = (pid) => {
206
+ events.push({ type: 'kill', pid });
207
+ const code = killFailures[String(pid)];
208
+ if (code !== undefined) {
209
+ const error = new Error(code);
210
+ error.code = code;
211
+ throw error;
212
+ }
213
+ alive.delete(pid);
214
+ };
215
+ const onTakeover = (takeover) => {
216
+ events.push({
217
+ type: 'takeover',
218
+ lockPath: takeover.lockPath,
219
+ holderPid: takeover.holderPid,
220
+ holderRunnerId: takeover.holderRunnerId,
221
+ reason: takeover.reason,
222
+ });
223
+ };
224
+ const runSetup = (setup) => {
225
+ for (const sub of setup ?? []) {
226
+ runAction(sub);
227
+ }
228
+ };
229
+ const acquireOptions = (action) => ({
230
+ machineId: action.machineId,
231
+ runnerId: action.runnerId,
232
+ cwd: action.cwd,
233
+ workspace: action.workspace ?? null,
234
+ ...(action.linzumiUrl === undefined ? {} : { linzumiUrl: action.linzumiUrl }),
235
+ configPath,
236
+ pid: action.pid,
237
+ ...(action.launchSource === undefined ? {} : { launchSource: action.launchSource }),
238
+ now,
239
+ isPidAlive,
240
+ cliVersion: action.cliVersion,
241
+ ...(action.gracefulUpdateDrain === undefined
242
+ ? {}
243
+ : { gracefulUpdateDrain: action.gracefulUpdateDrain }),
244
+ killPid,
245
+ onTakeover,
246
+ ...(action.beforeReadExistingLock === undefined
247
+ ? {}
248
+ : { beforeReadExistingLock: () => runSetup(action.beforeReadExistingLock) }),
249
+ ...(action.beforeReplaceStaleLock === undefined
250
+ ? {}
251
+ : { beforeReplaceStaleLock: () => runSetup(action.beforeReplaceStaleLock) }),
252
+ });
253
+ const runAction = (action) => {
254
+ switch (action.act) {
255
+ case 'setClock':
256
+ return outcome(() => {
257
+ clockMs = action.ms;
258
+ });
259
+ case 'setAlive':
260
+ return outcome(() => {
261
+ alive.clear();
262
+ for (const pid of action.pids) {
263
+ alive.add(pid);
264
+ }
265
+ });
266
+ case 'mkdir':
267
+ return outcome(() => {
268
+ mkdirSync(action.path, { recursive: true });
269
+ });
270
+ case 'writeFile':
271
+ return outcome(() => writeFileSync(action.path, action.contents, 'utf8'));
272
+ case 'unlink':
273
+ return outcome(() => unlinkSync(action.path));
274
+ case 'acquire':
275
+ return outcome(() => {
276
+ const handle = acquireRunnerLock(acquireOptions(action));
277
+ handles.set(action.id, handle);
278
+ return { path: handle.path };
279
+ });
280
+ case 'acquireIncoming':
281
+ return outcome(() => {
282
+ const handle = acquireIncomingGenerationRunnerLock({
283
+ ...acquireOptions(action),
284
+ handoffHolderPid: action.handoffHolderPid,
285
+ });
286
+ handles.set(action.id, handle);
287
+ return { path: handle.path, promoted: handle.promoted() };
288
+ });
289
+ case 'heartbeat':
290
+ return outcome(() => handles.get(action.id).heartbeat());
291
+ case 'release':
292
+ return outcome(() => handles.get(action.id).release());
293
+ case 'promote':
294
+ return outcome(() => handles.get(action.id).promote());
295
+ case 'promoted':
296
+ return outcome(() => handles.get(action.id).promoted());
297
+ case 'handlePath':
298
+ return outcome(() => handles.get(action.id).path);
299
+ case 'readOwner':
300
+ return outcome(() => readRunnerLockOwner(action.path));
301
+ default:
302
+ return { threw: true, name: 'Error', message: 'unknown lock action ' + String(action.act) };
303
+ }
304
+ };
305
+ const results = actions.map((action) => runAction(action));
306
+ return substitute(
307
+ { results, files: snapshotFiles(dir), events },
308
+ dir,
309
+ dirPlaceholder
310
+ );
311
+ }
312
+
313
+ // --- Takeover flow ----------------------------------------------------------------------
314
+
315
+ async function handleTakeoverFlow(testCase) {
316
+ const holder = testCase.holder;
317
+ const schedule = testCase.schedule ?? {};
318
+ let virtualMs = schedule.startMs ?? 0;
319
+ const sleeps = [];
320
+ const signals = [];
321
+ const reports = [];
322
+ const questions = [];
323
+ const logs = [];
324
+ const answers = [...(testCase.promptAnswers ?? [])];
325
+ const signalFailures = testCase.signalFailures ?? {};
326
+ const lockReleased = () =>
327
+ schedule.lockReleasedAtMs !== undefined && virtualMs >= schedule.lockReleasedAtMs;
328
+ const deps = {
329
+ isPidAlive: () =>
330
+ schedule.pidDeadAtMs === undefined ? true : virtualMs < schedule.pidDeadAtMs,
331
+ signalPid: (pid, signal) => {
332
+ signals.push({ pid, signal, atMs: virtualMs });
333
+ const code = signalFailures[signal];
334
+ if (code !== undefined) {
335
+ const error = new Error('scripted ' + signal + ' failure');
336
+ error.code = code;
337
+ throw error;
338
+ }
339
+ },
340
+ lockReleased,
341
+ currentLockOwner: () => {
342
+ if (schedule.ownerChange !== undefined && virtualMs >= schedule.ownerChange.atMs) {
343
+ return schedule.ownerChange.owner === null ? undefined : schedule.ownerChange.owner;
344
+ }
345
+ return holder;
346
+ },
347
+ sleep: (ms) => {
348
+ sleeps.push(ms);
349
+ virtualMs += ms;
350
+ return Promise.resolve();
351
+ },
352
+ now: () => virtualMs,
353
+ log: (event, payload) => {
354
+ logs.push({ event, payload });
355
+ },
356
+ };
357
+ const prompt = {
358
+ isInteractive: () => testCase.interactive === true,
359
+ writeReport: (text) => {
360
+ reports.push(text);
361
+ },
362
+ promptYesNo: (question) => {
363
+ questions.push(question);
364
+ return Promise.resolve(answers.length > 0 ? answers.shift() === true : false);
365
+ },
366
+ };
367
+ let result;
368
+ try {
369
+ const value = await resolveRunnerLockConflict({
370
+ holder,
371
+ lockPath: testCase.lockPath,
372
+ baseMessage: testCase.baseMessage,
373
+ takeOverWithoutPrompt: testCase.takeOverWithoutPrompt === true,
374
+ prompt,
375
+ takeover: deps,
376
+ });
377
+ result = { defined: true, value };
378
+ } catch (error) {
379
+ result = {
380
+ threw: true,
381
+ name: error instanceof Error ? error.name : 'Error',
382
+ message: error instanceof Error ? error.message : String(error),
383
+ };
384
+ }
385
+ return { outcome: result, sleeps, signals, reports, questions, logs, endMs: virtualMs };
386
+ }
387
+
388
+ // --- Dispatch --------------------------------------------------------------------------------
389
+
390
+ async function handle(testCase) {
391
+ switch (testCase.op) {
392
+ case 'constants':
393
+ return await handleConstants();
394
+ case 'lockPath':
395
+ return handleLockPath(testCase);
396
+ case 'incomingPath':
397
+ return outcome(() => incomingGenerationRunnerLockPath(testCase.primaryPath));
398
+ case 'compareVersions':
399
+ return outcome(() => compareCliVersions(testCase.a, testCase.b));
400
+ case 'assessHolder':
401
+ return handleAssessHolder(testCase);
402
+ case 'shouldAutoTakeOver':
403
+ return handleShouldAutoTakeOver(testCase);
404
+ case 'heldMessage':
405
+ return handleHeldMessage(testCase);
406
+ case 'conflictReport':
407
+ return outcome(() => runnerLockConflictReport(testCase.baseMessage));
408
+ case 'shouldResolve':
409
+ return outcome(() =>
410
+ shouldResolveRunnerLockConflict(testCase.defined === true ? {} : undefined)
411
+ );
412
+ case 'lockLifecycle':
413
+ return handleLockLifecycle(testCase);
414
+ case 'takeoverFlow':
415
+ return await handleTakeoverFlow(testCase);
416
+ default:
417
+ return { threw: true, name: 'Error', message: 'unknown op ' + String(testCase.op) };
418
+ }
419
+ }
420
+
421
+ let stdin = '';
422
+ process.stdin.setEncoding('utf8');
423
+ for await (const chunk of process.stdin) {
424
+ stdin += chunk;
425
+ }
426
+ const cases = JSON.parse(stdin);
427
+ const results = [];
428
+ for (const testCase of cases) {
429
+ results.push(await handle(testCase));
430
+ }
431
+ process.stdout.write(JSON.stringify(results));
432
+ `;
433
+
434
+ await mkdir(join(packageRoot, '.differential'), { recursive: true });
435
+
436
+ await build({
437
+ stdin: {
438
+ contents: driverSource,
439
+ resolveDir: packageRoot,
440
+ sourcefile: 'core-lock-parity-driver.ts',
441
+ loader: 'ts',
442
+ },
443
+ bundle: true,
444
+ platform: 'node',
445
+ target: 'node20',
446
+ format: 'esm',
447
+ outfile: join(packageRoot, '.differential/core-lock-parity-oracle.mjs'),
448
+ minify: false,
449
+ sourcemap: false,
450
+ legalComments: 'none',
451
+ // runnerLock.ts pulls localConfig/oauth; the same runtime externals as the
452
+ // sibling oracle builds stay external (installed in this package's
453
+ // node_modules; the oracle only exercises the lock/takeover exports).
454
+ external: ['ws', 'undici', 'blessed', '@anthropic-ai/claude-agent-sdk'],
455
+ });
@@ -0,0 +1,256 @@
1
+ // Spec: kandan/plans/2026-07-10-compute-nodes-rescript-commander-zero-ts-program.md
2
+ // (M3 cluster 10, commander core - the reconnect-resume recovery arm).
3
+ // Relationship: Builds the TS-side PARITY ORACLE for the reconnect-path
4
+ // resume orchestration of the ReScript commander port, the sibling of
5
+ // build-pipeline-parity-oracle.mjs (cluster 9) and
6
+ // build-harness-codex-parity-oracle.mjs (cluster 7). The oracle bundles
7
+ // the REAL runner.ts reconnect-resume exports - the I200 resume decision
8
+ // ladder (resumeCodexThreadForReconnect, transitively
9
+ // unarchiveAndResumeCodexThread, the archived/missing recognizers, and
10
+ // channelSession.ts's withCodexStartRequestTimeout) and the two rehydration
11
+ // fetch legs (fetchThreadRehydrationMessagesForTest /
12
+ // fetchThreadTranscriptForRehydrationForTest, transitively the
13
+ // reconnectContext.ts message parser and the transcriptCompiler.ts
14
+ // canonical-page parser) - behind the cluster 1/2 one-shot BATCH driver (a
15
+ // JSON array of cases on stdin, a JSON array of results on stdout).
16
+ //
17
+ // Scripting: the codex JSON-RPC request seam and the Phoenix push seam are
18
+ // per-case scripted reply queues (result / json-rpc error / rejection /
19
+ // never-settle hang); the rehydration builders are scripted
20
+ // (return/undefined/throw). Timeouts: the REAL withCodexStartRequestTimeout
21
+ // runs inside the bundle, bounded per case through its own
22
+ // LINZUMI_CODEX_START_REQUEST_TIMEOUT_MS knob (set from the case input
23
+ // before each run), so a scripted hang fails with the genuine sentinel
24
+ // bytes after a genuinely elapsed (small) bound. The batch loop holds a
25
+ // keepAlive interval while awaiting - the sentinel timer is unref'd by
26
+ // design and node would otherwise exit mid-hang (the cluster 7 lesson).
27
+ //
28
+ // Byte parity: every request (with params), push (with payload), log
29
+ // event (with payload), builder invocation, and the final
30
+ // outcome/thrown-message ride the returned trace VERBATIM (the TS
31
+ // objects' own key order rides JSON.stringify), so the ReScript side's
32
+ // trace serialization must reproduce field presence AND insertion order
33
+ // exactly.
34
+ //
35
+ // Import-time side effects: runner.ts's graph resolves assets/ relative
36
+ // to the bundle at import time (the hello-project scaffold's inlined
37
+ // logo), so the asset is copied next to the emitted oracle - the
38
+ // build-harness-codex-parity-oracle precedent. No other stubs are needed;
39
+ // the externals list matches the other oracles.
40
+ //
41
+ // SECRETS: the oracle only ever sees the conformance suite's synthetic
42
+ // payloads. The output is a local test artifact (.differential/ is
43
+ // gitignored, never published) and stays unminified for debuggability.
44
+ import { copyFile, mkdir } from 'node:fs/promises';
45
+ import { dirname, join } from 'node:path';
46
+ import { fileURLToPath } from 'node:url';
47
+ import { build } from 'esbuild';
48
+
49
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
50
+
51
+ const driverSource = `
52
+ import {
53
+ fetchThreadRehydrationMessagesForTest,
54
+ fetchThreadTranscriptForRehydrationForTest,
55
+ resumeCodexThreadForReconnect,
56
+ } from './src/runner';
57
+
58
+ // Scripted codex JSON-RPC request seam: replies are consumed in call
59
+ // order; every call is traced with its params. An exhausted script
60
+ // REJECTS loudly (mirrored byte-for-byte by the ReScript scripted seam).
61
+ function scriptedRequest(script, trace) {
62
+ let index = 0;
63
+ return (method, params) => {
64
+ trace.push({ t: 'request', method, params: params ?? null });
65
+ const step = script[index];
66
+ index += 1;
67
+ if (step === undefined) {
68
+ return Promise.reject(new Error('unscripted request: ' + method));
69
+ }
70
+ if (step.hang === true) {
71
+ return new Promise(() => {});
72
+ }
73
+ if (step.reject !== undefined) {
74
+ return Promise.reject(new Error(step.reject));
75
+ }
76
+ if (step.error !== undefined) {
77
+ return Promise.resolve({ error: step.error });
78
+ }
79
+ return Promise.resolve({ result: step.result ?? {} });
80
+ };
81
+ }
82
+
83
+ async function runResumeCase(input) {
84
+ // The REAL wrapper resolves its bound per call through this knob.
85
+ process.env.LINZUMI_CODEX_START_REQUEST_TIMEOUT_MS = String(
86
+ input.timeoutMs
87
+ );
88
+ const trace = [];
89
+ const request = scriptedRequest(input.requestScript ?? [], trace);
90
+ const rehydration = {
91
+ forceRehydrate: input.forceRehydrate === true,
92
+ buildRehydrationText: async (resumeFailure) => {
93
+ trace.push({ t: 'buildText', resumeFailure });
94
+ const script = input.textScript ?? {};
95
+ if (script.throw !== undefined) {
96
+ throw new Error(script.throw);
97
+ }
98
+ return script.text;
99
+ },
100
+ buildRehydrationItems:
101
+ input.itemsScript === undefined
102
+ ? undefined
103
+ : async (resumeFailure) => {
104
+ trace.push({ t: 'buildItems', resumeFailure });
105
+ const script = input.itemsScript;
106
+ if (script.throw !== undefined) {
107
+ throw new Error(script.throw);
108
+ }
109
+ return script.items;
110
+ },
111
+ log: (event, payload) => trace.push({ t: 'log', event, payload }),
112
+ };
113
+ const outcome = await resumeCodexThreadForReconnect(
114
+ { request },
115
+ input.codexThreadId,
116
+ input.resumeOverrides ?? {},
117
+ rehydration
118
+ ).then(
119
+ (value) => ({ ok: true, value }),
120
+ (error) => ({
121
+ ok: false,
122
+ message: error instanceof Error ? error.message : String(error),
123
+ })
124
+ );
125
+ trace.push({ t: 'outcome', outcome });
126
+ return { trace };
127
+ }
128
+
129
+ // Scripted Phoenix push seam shared by the two fetch legs.
130
+ function scriptedPush(script, trace) {
131
+ let index = 0;
132
+ return {
133
+ push: async (topic, event, payload) => {
134
+ trace.push({ t: 'push', topic, event, payload });
135
+ const step = script[index];
136
+ index += 1;
137
+ if (step === undefined) {
138
+ throw new Error('unscripted push: ' + event);
139
+ }
140
+ if (step.throw !== undefined) {
141
+ throw new Error(step.throw);
142
+ }
143
+ return step.reply;
144
+ },
145
+ };
146
+ }
147
+
148
+ function fetchTarget(input) {
149
+ return {
150
+ workspace: input.target.workspace ?? undefined,
151
+ channel: input.target.channel ?? undefined,
152
+ threadId: input.target.threadId ?? undefined,
153
+ };
154
+ }
155
+
156
+ async function runFetchMessagesCase(input) {
157
+ const trace = [];
158
+ const client = scriptedPush(input.pushScript ?? [], trace);
159
+ const messages = await fetchThreadRehydrationMessagesForTest(
160
+ client,
161
+ input.topic,
162
+ fetchTarget(input),
163
+ (event, payload) => trace.push({ t: 'log', event, payload })
164
+ );
165
+ trace.push({ t: 'messages', messages });
166
+ return { trace };
167
+ }
168
+
169
+ async function runFetchTranscriptCase(input) {
170
+ const trace = [];
171
+ const client = scriptedPush(input.pushScript ?? [], trace);
172
+ const transcript = await fetchThreadTranscriptForRehydrationForTest(
173
+ client,
174
+ input.topic,
175
+ fetchTarget(input),
176
+ (event, payload) => trace.push({ t: 'log', event, payload })
177
+ );
178
+ trace.push({ t: 'transcript', value: transcript ?? null });
179
+ return { trace };
180
+ }
181
+
182
+ async function runCase(input) {
183
+ switch (input.kind) {
184
+ case 'resumeRun':
185
+ return runResumeCase(input);
186
+ case 'fetchMessagesRun':
187
+ return runFetchMessagesCase(input);
188
+ case 'fetchTranscriptRun':
189
+ return runFetchTranscriptCase(input);
190
+ default:
191
+ return { error: 'unknown case kind ' + String(input.kind) };
192
+ }
193
+ }
194
+
195
+ async function runBatch() {
196
+ const chunks = [];
197
+ for await (const chunk of process.stdin) chunks.push(chunk);
198
+ const cases = JSON.parse(Buffer.concat(chunks).toString('utf8'));
199
+ const results = [];
200
+ for (const testCase of cases) {
201
+ // The sentinel timer is unref'd by design; hold the event loop open
202
+ // while a hang case waits it out or node would exit mid-await.
203
+ const keepAlive = setInterval(() => {}, 1000);
204
+ try {
205
+ results.push(await runCase(testCase));
206
+ } finally {
207
+ clearInterval(keepAlive);
208
+ }
209
+ }
210
+ process.stdout.write(JSON.stringify(results), () => {
211
+ process.exit(0);
212
+ });
213
+ }
214
+
215
+ runBatch().then(
216
+ () => {},
217
+ (error) => {
218
+ process.stderr.write(String(error && error.stack ? error.stack : error) + '\\n');
219
+ process.exit(1);
220
+ }
221
+ );
222
+ `;
223
+
224
+ const outDir = join(packageRoot, '.differential');
225
+ const outPath = join(outDir, 'core-reconnect-resume-parity-oracle.mjs');
226
+
227
+ await mkdir(outDir, { recursive: true });
228
+ // runner.ts's graph resolves assets/ relative to the bundle at import time
229
+ // (the hello-project scaffold's inlined logo), so the asset must sit next
230
+ // to the emitted oracle - the build-harness-codex-parity-oracle precedent.
231
+ await mkdir(join(outDir, 'assets'), { recursive: true });
232
+ await copyFile(
233
+ join(packageRoot, 'src/assets/linzumi-logo.svg'),
234
+ join(outDir, 'assets/linzumi-logo.svg')
235
+ );
236
+
237
+ await build({
238
+ stdin: {
239
+ contents: driverSource,
240
+ resolveDir: packageRoot,
241
+ sourcefile: 'core-reconnect-resume-parity-driver.ts',
242
+ loader: 'ts',
243
+ },
244
+ bundle: true,
245
+ platform: 'node',
246
+ target: 'node20',
247
+ format: 'esm',
248
+ outfile: outPath,
249
+ minify: false,
250
+ sourcemap: false,
251
+ legalComments: 'none',
252
+ logLevel: 'silent',
253
+ external: ['ws', 'undici', 'blessed', '@anthropic-ai/claude-agent-sdk'],
254
+ });
255
+
256
+ process.stdout.write(outPath + '\n');