@linzumi/cli 1.0.110 → 1.0.112
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 +1 -1
- package/package.json +1 -1
- package/scripts/build-core-commander-seams-parity-oracle.mjs +586 -0
- package/scripts/build-core-daemon-parity-oracle.mjs +573 -0
- package/scripts/build-core-dispatch-input-queue-parity-oracle.mjs +219 -0
- package/scripts/build-core-fate-ladder-parity-oracle.mjs +250 -0
- package/scripts/build-core-generation-overlap-parity-oracle.mjs +288 -0
- package/scripts/build-core-integration-parity-oracle.mjs +361 -0
- package/scripts/build-core-lifecycle-guards-parity-oracle.mjs +443 -0
- package/scripts/build-core-lock-parity-oracle.mjs +455 -0
- package/scripts/build-core-reconnect-resume-parity-oracle.mjs +256 -0
- package/scripts/build-core-startup-resume-scan-parity-oracle.mjs +296 -0
- package/scripts/build-core-supervisor-parity-oracle.mjs +734 -0
- package/scripts/build-pipeline-parity-oracle.mjs +610 -0
|
@@ -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');
|