@linzumi/cli 1.0.101 → 1.0.103
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-queue-parity-oracle.mjs +899 -0
- package/scripts/bump-cli-version.mjs +25 -1
package/README.md
CHANGED
package/dist/index.js
CHANGED
|
@@ -1471,7 +1471,7 @@ To kick the agent off:
|
|
|
1471
1471
|
`,L=Buffer.byteLength(P,"utf8");if(L>s)return l("runner_diagnostics.spool_entry_dropped",{reason:"entry_too_large",entry_bytes:L,max_entry_bytes:s}),!1;uSe(n,{recursive:!0}),m(u)+L>i&&f(),_(L);let D=w()?`
|
|
1472
1472
|
`:"";return sSe(u,`${D}${P}`,"utf8"),!0}catch(R){return l("runner_diagnostics.spool_failed",{error:R instanceof Error?R.message:String(R)}),!1}},v=k=>{let R=[],P=lSe(k,"utf8");for(let L of P.split(`
|
|
1473
1473
|
`))if(L.trim()!=="")try{let D=JSON.parse(L);D!==null&&typeof D=="object"&&R.push({parsed:D,raw:L})}catch{}return R};return{append:S,drain:async k=>{if(c)return{uploadedBatches:0,uploadedEntries:0,remainingBatches:p().length,skipped:!0};c=!0;let R=0,P=0;try{f(),_(0);let L=a()-o;for(let D of p()){let j;try{j=v(D)}catch(Z){l("runner_diagnostics.spool_batch_unreadable",{file:D,error:Z instanceof Error?Z.message:String(Z)}),y(D,"unreadable");continue}let q=j.filter(Z=>{let fe=ISe(Z.parsed);return fe===void 0||fe>=L});if(q.length===0){y(D,"empty_or_expired");continue}let E=oP(D,".jsonl"),B=q.map(Z=>Z.raw).join(`
|
|
1474
|
-
`),z=q.map(Z=>Z.parsed.spooled_at).filter(Z=>typeof Z=="string"),W={bundle_name:"runner-diagnostics-spool",exported_at:new Date(a()).toISOString(),idempotency_key:E,diagnostics:{kind:"runner_diagnostic_log",spool_batch:!0,idempotency_key:E,entry_count:q.length,...z.length===0?{}:{first_spooled_at:z[0],last_spooled_at:z[z.length-1]}},telemetry_ndjson:B};try{await k(W)}catch(Z){l("runner_diagnostics.spool_upload_failed",{file:D,remaining_batches:p().length,error:Z instanceof Error?Z.message:String(Z)});break}R+=1,P+=q.length;try{h9(D)}catch(Z){l("runner_diagnostics.spool_delete_failed",{file:D,error:Z instanceof Error?Z.message:String(Z)})}}}catch(L){l("runner_diagnostics.spool_drain_failed",{error:L instanceof Error?L.message:String(L)})}finally{c=!1}return{uploadedBatches:R,uploadedEntries:P,remainingBatches:p().length,skipped:!1}},pendingBatchFiles:p}}var ySe,xSe,_Se,bSe,wSe,g9,SSe,kSe,x9=F(()=>{"use strict";Xy();ySe=32*1024*1024,xSe=10080*60*1e3,_Se=4*1024*1024,bSe=1024*1024,wSe="spool.jsonl",g9=/^batch-(\d{1,16})-[0-9a-f-]+\.jsonl$/;SSe=/\b([A-Z][A-Z0-9_]{1,})=("[^"\n]*"|'[^'\n]*'|[^\s"'`;,)\]}]+)/g,kSe=/(?:\/(?:[A-Za-z0-9._@~+-]|%[0-9A-Fa-f]{2})+){2,}/g});var jt,sP,ea=F(()=>{"use strict";jt="1.0.
|
|
1474
|
+
`),z=q.map(Z=>Z.parsed.spooled_at).filter(Z=>typeof Z=="string"),W={bundle_name:"runner-diagnostics-spool",exported_at:new Date(a()).toISOString(),idempotency_key:E,diagnostics:{kind:"runner_diagnostic_log",spool_batch:!0,idempotency_key:E,entry_count:q.length,...z.length===0?{}:{first_spooled_at:z[0],last_spooled_at:z[z.length-1]}},telemetry_ndjson:B};try{await k(W)}catch(Z){l("runner_diagnostics.spool_upload_failed",{file:D,remaining_batches:p().length,error:Z instanceof Error?Z.message:String(Z)});break}R+=1,P+=q.length;try{h9(D)}catch(Z){l("runner_diagnostics.spool_delete_failed",{file:D,error:Z instanceof Error?Z.message:String(Z)})}}}catch(L){l("runner_diagnostics.spool_drain_failed",{error:L instanceof Error?L.message:String(L)})}finally{c=!1}return{uploadedBatches:R,uploadedEntries:P,remainingBatches:p().length,skipped:!1}},pendingBatchFiles:p}}var ySe,xSe,_Se,bSe,wSe,g9,SSe,kSe,x9=F(()=>{"use strict";Xy();ySe=32*1024*1024,xSe=10080*60*1e3,_Se=4*1024*1024,bSe=1024*1024,wSe="spool.jsonl",g9=/^batch-(\d{1,16})-[0-9a-f-]+\.jsonl$/;SSe=/\b([A-Z][A-Z0-9_]{1,})=("[^"\n]*"|'[^'\n]*'|[^\s"'`;,)\]}]+)/g,kSe=/(?:\/(?:[A-Za-z0-9._@~+-]|%[0-9A-Fa-f]{2})+){2,}/g});var jt,sP,ea=F(()=>{"use strict";jt="1.0.103",sP=`linzumi ${jt}`});import{closeSync as b9,existsSync as dP,mkdirSync as RSe,openSync as w9,readFileSync as v9,renameSync as ESe,unlinkSync as gd,writeFileSync as DSe,writeSync as S9}from"node:fs";import{dirname as k9,join as MSe}from"node:path";function uP(e){return e instanceof hs||e instanceof Error&&e.name===T9&&"lockPath"in e&&"heldBy"in e}function cP(e,n=Wn(),t){let r=t===void 0?encodeURIComponent(e):dd(t);return MSe(k9(n),"runners",`${r}.lock`)}function u0(e){let n=cP(e.machineId,e.configPath,e.linzumiUrl),t=e.isPidAlive??A9,r=e.now??(()=>new Date),o=e.killPid??KSe,i=r().toISOString(),s={version:1,machineId:e.machineId,runnerId:e.runnerId,pid:e.pid??process.pid,cwd:e.cwd,workspace:e.workspace,...e.linzumiUrl===void 0?{}:{linzumiUrl:qn(e.linzumiUrl)},...e.launchSource===void 0?{}:{launchSource:e.launchSource},startedAt:i,heartbeatAt:i,cliVersion:e.cliVersion??jt,...e.gracefulUpdateDrain===!0?{gracefulUpdateDrain:!0}:{}};LSe(e.machineId,e.configPath,e.linzumiUrl,t,r,o,e.onTakeover),jSe(n,s,t,r,o,e.onTakeover,e.beforeReadExistingLock,e.beforeReplaceStaleLock);let a=()=>P9(n,s,r),d=setInterval(a,C9);return d.unref(),{path:n,record:s,heartbeat:a,release:()=>{clearInterval(d),D9(n,s)}}}function P9(e,n,t){try{let r=l0(e);if(r===void 0||r.machineId!==n.machineId||r.runnerId!==n.runnerId||r.pid!==n.pid||r.startedAt!==n.startedAt)return;let o={...r,heartbeatAt:t().toISOString()},i=`${e}.tmp`;DSe(i,`${JSON.stringify(o,null,2)}
|
|
1475
1475
|
`,"utf8"),ESe(i,e)}catch{}}function yd(e,n,t){if(!n(e.pid))return{state:"dead"};let r=e.heartbeatAt??e.startedAt,o=Date.parse(r);return Number.isNaN(o)||t().getTime()-o>ASe?{state:"wedged",reason:e.heartbeatAt===void 0?"missing_heartbeat":"stale_heartbeat"}:{state:"live"}}function I9(e,n,t,r){return t!==c0||e.launchSource!==c0?!1:r.state!=="live"?!0:$Se(n,e.cliVersion)>0}function $Se(e,n){let t=_9(e),r=_9(n);for(let o=0;o<3;o++){let i=t.core[o],s=r.core[o];if(i===void 0||s===void 0)throw new Error(`compareCliVersions: unexpected undefined at core index ${o}`);if(i!==s)return i>s?1:-1}return t.prerelease.length===0&&r.prerelease.length===0?0:t.prerelease.length===0?1:r.prerelease.length===0?-1:OSe(t.prerelease,r.prerelease)}function _9(e){let n=e.trim().replace(/^v/i,"").split("+",1)[0]??"",t=n.indexOf("-"),r=t===-1?n:n.slice(0,t),o=t===-1?"":n.slice(t+1),i=r.split("."),s=[aP(i[0]),aP(i[1]),aP(i[2])],a=o===""?[]:o.split(".").filter(d=>d!=="");return{core:s,prerelease:a}}function aP(e){let n=Number.parseInt(e??"",10);return Number.isInteger(n)&&n>=0?n:0}function OSe(e,n){let t=Math.max(e.length,n.length);for(let r=0;r<t;r++){let o=e[r],i=n[r];if(o===void 0)return-1;if(i===void 0)return 1;let s=Number.parseInt(o,10),a=Number.parseInt(i,10),d=s.toString()===o,u=a.toString()===i;if(d&&u){if(s!==a)return s>a?1:-1}else{if(d!==u)return d?-1:1;if(o!==i)return o>i?1:-1}}return 0}function R9(e,n,t,r,o){try{r(n.pid)}catch(i){let s=i!==null&&typeof i=="object"&&"code"in i?i.code:void 0;if(s!=="ESRCH")throw s==="EPERM"||s==="EACCES"?new Error(`Cannot take over the runner lock at ${e}: its holder process (pid ${n.pid}) is owned by another user and cannot be stopped. Stop that process, or remove the lock file, then retry.`):i}o?.({lockPath:e,holderPid:n.pid,holderRunnerId:n.runnerId,reason:t})}function LSe(e,n,t,r,o,i,s){if(t===void 0||yr(t)!==yr(Bt))return;let a=cP(e,n),d=l0(a);if(d!==void 0){if(yd(d,r,o).state==="live")throw new hs(a,d);E9(a,r,()=>{let u=dP(a)?lP(a):void 0;if(u===void 0)return;let c=yd(u,r,o);if(c.state==="live")throw new hs(a,u);c.state==="wedged"&&R9(a,u,c.reason,i,s),gd(a)})}}function jSe(e,n,t,r,o,i,s,a){if(d0(e,n))return;s?.();let d=l0(e);if(d===void 0){if(d0(e,n))return;throw new Error(`another Linzumi runner lock appeared while starting: ${e}`)}if(yd(d,t,r).state==="live")throw new hs(e,d);a?.(),E9(e,t,()=>{let u=dP(e)?lP(e):void 0;if(u!==void 0){let c=yd(u,t,r);if(c.state==="live")throw new hs(e,u);c.state==="wedged"&&R9(e,u,c.reason,o,i),gd(e)}if(!d0(e,n))throw new Error(`another Linzumi runner lock appeared while starting: ${e}`)})}function d0(e,n){RSe(k9(e),{recursive:!0});try{let t=w9(e,"wx");try{S9(t,`${JSON.stringify(n,null,2)}
|
|
1476
1476
|
`)}finally{b9(t)}return!0}catch(t){if(Bc(t,"EEXIST"))return!1;throw t}}function E9(e,n,t){let r=`${e}.replace`;for(;;)try{let o=w9(r,"wx");try{S9(o,`${process.pid}
|
|
1477
1477
|
`),t()}finally{b9(o),gd(r)}return}catch(o){if(Bc(o,"EEXIST")){let i=FSe(r);if(i===void 0)continue;if(n(i))throw new Error(["another Linzumi runner is already replacing a stale runner lock",`lock: ${e}`,"Wait for that startup to finish, then retry."].join(`
|
package/package.json
CHANGED
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
// Spec: kandan/plans/2026-07-10-compute-nodes-rescript-commander-zero-ts-program.md
|
|
2
|
+
// (M3 cluster 5, queues-watermarks).
|
|
3
|
+
// Relationship: Builds the TS-side PARITY ORACLE for the queues/watermarks
|
|
4
|
+
// cluster of the ReScript commander port, the sibling of
|
|
5
|
+
// build-config-identity-parity-oracle.mjs (cluster 2). The oracle bundles
|
|
6
|
+
// the REAL TS queue/watermark modules (kandanQueue.ts,
|
|
7
|
+
// pendingKandanMessageQueue.ts, appliedSourceSeqWatermark.ts,
|
|
8
|
+
// durableInboundQueue.ts, controlQuarantine.ts, resumeKeystone.ts +
|
|
9
|
+
// threadReconcilerLock.ts/leaseEpochGuard.ts, pipeline/outbox.ts +
|
|
10
|
+
// pipeline/outboxJournal.ts) behind the cluster 1/2 one-shot batch driver:
|
|
11
|
+
// a JSON array of cases on stdin, a JSON array of results on stdout. The
|
|
12
|
+
// ReScript package's QueueParityConformance drives the SAME scripted
|
|
13
|
+
// inputs through the ported modules in its own scratch directories and
|
|
14
|
+
// asserts per-action outcome equivalence AND byte-identical durable files
|
|
15
|
+
// (inbound-queue logs, commander-outbox journals, withheld-ack records,
|
|
16
|
+
// quarantine evidence) - the cross-impl round-trip contract that lets both
|
|
17
|
+
// commanders share ~/.linzumi through M4.
|
|
18
|
+
//
|
|
19
|
+
// Determinism: every source of nondeterminism is pinned per case - clocks
|
|
20
|
+
// come from a fixed `nowMs` (the queue/outbox/keystone seams are all
|
|
21
|
+
// injectable), scripted push outcomes resolve in order, and scratch paths
|
|
22
|
+
// are normalized through the "$DIR" placeholder in both directions. Retry
|
|
23
|
+
// delays in scripted outbox scenarios are millisecond-scale so a scenario
|
|
24
|
+
// settles fast without changing the ladder's shape.
|
|
25
|
+
//
|
|
26
|
+
// SECRETS: the oracle only ever sees the conformance suite's synthetic
|
|
27
|
+
// payloads; nothing here logs case contents.
|
|
28
|
+
//
|
|
29
|
+
// The output is a local test artifact (.differential/ is gitignored, never
|
|
30
|
+
// published) and stays unminified for debuggability.
|
|
31
|
+
import { mkdir } from 'node:fs/promises';
|
|
32
|
+
import { dirname, join } from 'node:path';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
import { build } from 'esbuild';
|
|
35
|
+
|
|
36
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
37
|
+
|
|
38
|
+
const driverSource = `
|
|
39
|
+
import {
|
|
40
|
+
appendFileSync,
|
|
41
|
+
mkdtempSync,
|
|
42
|
+
mkdirSync,
|
|
43
|
+
readdirSync,
|
|
44
|
+
readFileSync,
|
|
45
|
+
realpathSync,
|
|
46
|
+
statSync,
|
|
47
|
+
writeFileSync,
|
|
48
|
+
} from 'node:fs';
|
|
49
|
+
import { tmpdir } from 'node:os';
|
|
50
|
+
import { join, relative } from 'node:path';
|
|
51
|
+
import {
|
|
52
|
+
codexInputForQueuedKandanMessage,
|
|
53
|
+
formatQueuedKandanMessage,
|
|
54
|
+
interruptQueuedMessages,
|
|
55
|
+
normalizeCodexSteerInput,
|
|
56
|
+
queuedKandanMessageSourceSeqs,
|
|
57
|
+
steerInputText,
|
|
58
|
+
} from './src/kandanQueue';
|
|
59
|
+
import {
|
|
60
|
+
createPendingKandanMessageQueue,
|
|
61
|
+
dequeuePendingKandanMessage,
|
|
62
|
+
drainAllPendingKandanMessages,
|
|
63
|
+
enqueuePendingKandanMessage,
|
|
64
|
+
interruptPendingKandanMessages,
|
|
65
|
+
listPendingKandanMessages,
|
|
66
|
+
pendingKandanMessageQueueHasSeq,
|
|
67
|
+
pendingKandanMessageQueueLength,
|
|
68
|
+
requeuePendingKandanMessageFront,
|
|
69
|
+
} from './src/pendingKandanMessageQueue';
|
|
70
|
+
import {
|
|
71
|
+
commitAppliedSourceSeq,
|
|
72
|
+
createInFlightSourceSeqGuard,
|
|
73
|
+
highestProcessedSourceSeq,
|
|
74
|
+
parseResumeReconcileEntries,
|
|
75
|
+
parseServerMessageStateRows,
|
|
76
|
+
reconcileAppliedSourceSeq,
|
|
77
|
+
resumeReconcileServerRows,
|
|
78
|
+
shouldApplySourceSeq,
|
|
79
|
+
sourceSeqAppliedOrInFlight,
|
|
80
|
+
threadSourceSeqWatermarkKey,
|
|
81
|
+
} from './src/appliedSourceSeqWatermark';
|
|
82
|
+
import {
|
|
83
|
+
createDurableInboundThreadQueue,
|
|
84
|
+
defaultDeadLetterPolicy,
|
|
85
|
+
durableInboundQueuePath,
|
|
86
|
+
foldQueueRecords,
|
|
87
|
+
shouldDeadLetter,
|
|
88
|
+
} from './src/durableInboundQueue';
|
|
89
|
+
import {
|
|
90
|
+
controlQuarantineFilePath,
|
|
91
|
+
createControlQuarantine,
|
|
92
|
+
fileWithheldControlAckStore,
|
|
93
|
+
withheldControlAckStorePath,
|
|
94
|
+
} from './src/controlQuarantine';
|
|
95
|
+
import {
|
|
96
|
+
outboxJournalPath,
|
|
97
|
+
recoverOutboxJournal,
|
|
98
|
+
} from './src/pipeline/outboxJournal';
|
|
99
|
+
import { createThreadOutbox } from './src/pipeline/outbox';
|
|
100
|
+
import {
|
|
101
|
+
applyResumeDispatchOutcome,
|
|
102
|
+
commitLiveAcceptedSourceSeq,
|
|
103
|
+
drainResumeQueue,
|
|
104
|
+
receiveResumeMessage,
|
|
105
|
+
} from './src/resumeKeystone';
|
|
106
|
+
import { createLeaseEpochRegistry, isFencedClaim } from './src/leaseEpochGuard';
|
|
107
|
+
import { createThreadReconcilerLock } from './src/threadReconcilerLock';
|
|
108
|
+
|
|
109
|
+
// --- Placeholder + snapshot plumbing --------------------------------------------
|
|
110
|
+
|
|
111
|
+
const dirPlaceholder = '$DIR';
|
|
112
|
+
|
|
113
|
+
function scratch() {
|
|
114
|
+
// realpath so macOS /var -> /private/var never splits the two impls'
|
|
115
|
+
// placeholder substitution.
|
|
116
|
+
return realpathSync(mkdtempSync(join(tmpdir(), 'linzumi-queue-oracle-')));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function substitute(value, from, to) {
|
|
120
|
+
if (typeof value === 'string') {
|
|
121
|
+
return value.split(from).join(to);
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(value)) {
|
|
124
|
+
return value.map((entry) => substitute(entry, from, to));
|
|
125
|
+
}
|
|
126
|
+
if (typeof value === 'object' && value !== null) {
|
|
127
|
+
return Object.fromEntries(
|
|
128
|
+
Object.entries(value).map(([key, entry]) => [
|
|
129
|
+
substitute(key, from, to),
|
|
130
|
+
substitute(entry, from, to),
|
|
131
|
+
])
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function snapshotFiles(dir) {
|
|
138
|
+
const files = {};
|
|
139
|
+
const walk = (current) => {
|
|
140
|
+
for (const name of readdirSync(current).sort()) {
|
|
141
|
+
const full = join(current, name);
|
|
142
|
+
if (statSync(full).isDirectory()) {
|
|
143
|
+
walk(full);
|
|
144
|
+
} else {
|
|
145
|
+
files[relative(dir, full)] = readFileSync(full, 'utf8');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
walk(dir);
|
|
150
|
+
return files;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function outcome(run) {
|
|
154
|
+
try {
|
|
155
|
+
const value = run();
|
|
156
|
+
return value === undefined ? { defined: false } : { defined: true, value };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return {
|
|
159
|
+
threw: true,
|
|
160
|
+
message: error instanceof Error ? error.message : String(error),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function collectingLog(events, dir) {
|
|
166
|
+
return (event, payload) => {
|
|
167
|
+
events.push({ event, payload: substitute(payload, dir, dirPlaceholder) });
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- Queued-message pure transforms ------------------------------------------------
|
|
172
|
+
|
|
173
|
+
function handleQueuedMessage(testCase) {
|
|
174
|
+
switch (testCase.fn) {
|
|
175
|
+
case 'normalizeCodexSteerInput':
|
|
176
|
+
return outcome(() => normalizeCodexSteerInput(testCase.input));
|
|
177
|
+
case 'steerInputText':
|
|
178
|
+
return outcome(() => steerInputText(testCase.input));
|
|
179
|
+
case 'format':
|
|
180
|
+
return outcome(() => formatQueuedKandanMessage(testCase.message));
|
|
181
|
+
case 'codexInput':
|
|
182
|
+
return outcome(() => codexInputForQueuedKandanMessage(testCase.message));
|
|
183
|
+
case 'sourceSeqs':
|
|
184
|
+
return outcome(() => queuedKandanMessageSourceSeqs(testCase.message));
|
|
185
|
+
case 'interrupt':
|
|
186
|
+
return outcome(() =>
|
|
187
|
+
interruptQueuedMessages(testCase.queue, testCase.throughSeq ?? undefined)
|
|
188
|
+
);
|
|
189
|
+
default:
|
|
190
|
+
return { threw: true, message: 'unknown queuedMessage fn ' + String(testCase.fn) };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- Pending FIFO ---------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
function handlePendingQueue(testCase) {
|
|
197
|
+
const queue = createPendingKandanMessageQueue();
|
|
198
|
+
const results = (testCase.actions ?? []).map((action) => {
|
|
199
|
+
switch (action.fn) {
|
|
200
|
+
case 'enqueue':
|
|
201
|
+
return outcome(() => enqueuePendingKandanMessage(queue, action.message));
|
|
202
|
+
case 'dequeue':
|
|
203
|
+
return outcome(() => dequeuePendingKandanMessage(queue));
|
|
204
|
+
case 'requeueFront':
|
|
205
|
+
return outcome(() => requeuePendingKandanMessageFront(queue, action.message));
|
|
206
|
+
case 'drainAll':
|
|
207
|
+
return outcome(() => drainAllPendingKandanMessages(queue));
|
|
208
|
+
case 'interrupt':
|
|
209
|
+
return outcome(() =>
|
|
210
|
+
interruptPendingKandanMessages(queue, action.throughSeq ?? undefined)
|
|
211
|
+
);
|
|
212
|
+
case 'hasSeq':
|
|
213
|
+
return outcome(() => pendingKandanMessageQueueHasSeq(queue, action.seq));
|
|
214
|
+
case 'list':
|
|
215
|
+
return outcome(() => listPendingKandanMessages(queue));
|
|
216
|
+
case 'length':
|
|
217
|
+
return outcome(() => pendingKandanMessageQueueLength(queue));
|
|
218
|
+
default:
|
|
219
|
+
return { threw: true, message: 'unknown pendingQueue fn ' + String(action.fn) };
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
return { results };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- Watermark pure semantics -----------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
function watermarkMemStore(calls) {
|
|
228
|
+
const map = new Map();
|
|
229
|
+
return {
|
|
230
|
+
initial: (key) => map.get(key),
|
|
231
|
+
onAdvance: (key, seq) => {
|
|
232
|
+
calls.push({ call: 'onAdvance', key, seq });
|
|
233
|
+
const existing = map.get(key);
|
|
234
|
+
if (existing === undefined || seq > existing) {
|
|
235
|
+
map.set(key, seq);
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
flush: () => {
|
|
239
|
+
calls.push({ call: 'flush' });
|
|
240
|
+
},
|
|
241
|
+
dump: () => Object.fromEntries(map),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function handleWatermark(testCase) {
|
|
246
|
+
switch (testCase.fn) {
|
|
247
|
+
case 'shouldApply':
|
|
248
|
+
return outcome(() =>
|
|
249
|
+
shouldApplySourceSeq(testCase.applied ?? undefined, testCase.sourceSeq)
|
|
250
|
+
);
|
|
251
|
+
case 'key':
|
|
252
|
+
return outcome(() =>
|
|
253
|
+
threadSourceSeqWatermarkKey(testCase.workspace, testCase.channel, testCase.threadId)
|
|
254
|
+
);
|
|
255
|
+
case 'highestProcessed':
|
|
256
|
+
return outcome(() => highestProcessedSourceSeq(testCase.rows));
|
|
257
|
+
case 'reconcile':
|
|
258
|
+
return outcome(() =>
|
|
259
|
+
reconcileAppliedSourceSeq(testCase.local ?? undefined, testCase.rows)
|
|
260
|
+
);
|
|
261
|
+
case 'parseRows':
|
|
262
|
+
return outcome(() => parseServerMessageStateRows(testCase.value));
|
|
263
|
+
case 'parseResumeReconcile':
|
|
264
|
+
return outcome(() => parseResumeReconcileEntries(testCase.value));
|
|
265
|
+
case 'resumeReconcileRows':
|
|
266
|
+
return outcome(() => resumeReconcileServerRows(testCase.entry));
|
|
267
|
+
case 'isFencedClaim':
|
|
268
|
+
return outcome(() =>
|
|
269
|
+
isFencedClaim(testCase.held ?? undefined, testCase.control ?? undefined)
|
|
270
|
+
);
|
|
271
|
+
case 'guardScenario': {
|
|
272
|
+
const calls = [];
|
|
273
|
+
const store = watermarkMemStore(calls);
|
|
274
|
+
const guard = createInFlightSourceSeqGuard();
|
|
275
|
+
const results = (testCase.actions ?? []).map((action) => {
|
|
276
|
+
switch (action.fn) {
|
|
277
|
+
case 'mark':
|
|
278
|
+
return outcome(() => guard.mark(action.key, action.seq));
|
|
279
|
+
case 'has':
|
|
280
|
+
return outcome(() => guard.has(action.key, action.seq));
|
|
281
|
+
case 'clearSeq':
|
|
282
|
+
return outcome(() => guard.clearSeq(action.key, action.seq));
|
|
283
|
+
case 'clearThread':
|
|
284
|
+
return outcome(() => guard.clearThread(action.key));
|
|
285
|
+
case 'commit':
|
|
286
|
+
return outcome(() => commitAppliedSourceSeq(store, action.key, action.seq));
|
|
287
|
+
case 'commitLiveAccepted':
|
|
288
|
+
return outcome(() =>
|
|
289
|
+
commitLiveAcceptedSourceSeq({
|
|
290
|
+
store,
|
|
291
|
+
watermarkKey: action.key,
|
|
292
|
+
sourceSeq: action.seq,
|
|
293
|
+
})
|
|
294
|
+
);
|
|
295
|
+
case 'appliedOrInFlight':
|
|
296
|
+
return outcome(() =>
|
|
297
|
+
sourceSeqAppliedOrInFlight(
|
|
298
|
+
store.initial(action.key),
|
|
299
|
+
guard,
|
|
300
|
+
action.key,
|
|
301
|
+
action.seq
|
|
302
|
+
)
|
|
303
|
+
);
|
|
304
|
+
default:
|
|
305
|
+
return { threw: true, message: 'unknown guard fn ' + String(action.fn) };
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
return { results, storeCalls: calls, store: store.dump() };
|
|
309
|
+
}
|
|
310
|
+
default:
|
|
311
|
+
return { threw: true, message: 'unknown watermark fn ' + String(testCase.fn) };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- Durable inbound queue --------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
function normalizeEntry(entry) {
|
|
318
|
+
return entry === undefined ? undefined : { ...entry };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function handleInboundQueue(testCase) {
|
|
322
|
+
if (testCase.fn === 'foldLines') {
|
|
323
|
+
return outcome(() => foldQueueRecords(testCase.lines));
|
|
324
|
+
}
|
|
325
|
+
if (testCase.fn === 'shouldDeadLetter') {
|
|
326
|
+
return outcome(() =>
|
|
327
|
+
shouldDeadLetter(testCase.entry, testCase.policy ?? defaultDeadLetterPolicy, testCase.nowMs)
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (testCase.fn === 'path') {
|
|
331
|
+
process.env.LINZUMI_INBOUND_QUEUE_DIR = testCase.envDir ?? '';
|
|
332
|
+
const result = outcome(() =>
|
|
333
|
+
durableInboundQueuePath(testCase.workspace, testCase.channel, testCase.threadId)
|
|
334
|
+
);
|
|
335
|
+
delete process.env.LINZUMI_INBOUND_QUEUE_DIR;
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
338
|
+
// Scripted lifecycle in a scratch dir. fsync spies record ORDERING facts
|
|
339
|
+
// (an 'fsync' event lands in the stream before the action result is
|
|
340
|
+
// recorded), proving fsync-before-return (#2974) on BOTH impls.
|
|
341
|
+
const dir = scratch();
|
|
342
|
+
const path = join(dir, 'thread.log');
|
|
343
|
+
if (testCase.initialContents !== undefined) {
|
|
344
|
+
writeFileSync(path, testCase.initialContents, 'utf8');
|
|
345
|
+
}
|
|
346
|
+
const events = [];
|
|
347
|
+
const log = collectingLog(events, dir);
|
|
348
|
+
const deps = {
|
|
349
|
+
fsyncFd: () => {
|
|
350
|
+
events.push({ event: 'spy.fsync_data' });
|
|
351
|
+
},
|
|
352
|
+
fsyncDirFd: () => {
|
|
353
|
+
events.push({ event: 'spy.fsync_dir' });
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
let queue = createDurableInboundThreadQueue(path, log, deps);
|
|
357
|
+
const results = (testCase.actions ?? []).map((action) => {
|
|
358
|
+
switch (action.fn) {
|
|
359
|
+
case 'enqueue': {
|
|
360
|
+
const result = outcome(() =>
|
|
361
|
+
queue.enqueue(action.sourceSeq, action.kind ?? 'live', action.payload ?? {}, action.nowMs)
|
|
362
|
+
);
|
|
363
|
+
events.push({ event: 'action.enqueue_returned', sourceSeq: action.sourceSeq });
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
case 'ack':
|
|
367
|
+
return outcome(() => queue.ack(action.sourceSeq));
|
|
368
|
+
case 'recordAttempt':
|
|
369
|
+
return outcome(() => queue.recordAttempt(action.sourceSeq));
|
|
370
|
+
case 'deadLetter':
|
|
371
|
+
return outcome(() => normalizeEntry(queue.deadLetter(action.sourceSeq)));
|
|
372
|
+
case 'compact':
|
|
373
|
+
return outcome(() => queue.compact());
|
|
374
|
+
case 'entries':
|
|
375
|
+
return outcome(() => queue.entries().map(normalizeEntry));
|
|
376
|
+
case 'ackedEntries':
|
|
377
|
+
return outcome(() => queue.ackedEntries().map(normalizeEntry));
|
|
378
|
+
case 'head':
|
|
379
|
+
return outcome(() => normalizeEntry(queue.head()));
|
|
380
|
+
case 'highWater':
|
|
381
|
+
return outcome(() => queue.highWaterSourceSeq());
|
|
382
|
+
case 'tornTail':
|
|
383
|
+
return outcome(() => queue.tornTail());
|
|
384
|
+
case 'reconciledTornTail':
|
|
385
|
+
return outcome(() => queue.reconciledTornTail());
|
|
386
|
+
case 'appendRaw':
|
|
387
|
+
// Simulate a crash-torn tail / foreign bytes.
|
|
388
|
+
return outcome(() => appendFileSync(path, action.raw, 'utf8'));
|
|
389
|
+
case 'reload': {
|
|
390
|
+
// Simulated SIGKILL + restart: a fresh handle folds the same file.
|
|
391
|
+
queue = createDurableInboundThreadQueue(path, log, deps);
|
|
392
|
+
return { defined: false };
|
|
393
|
+
}
|
|
394
|
+
default:
|
|
395
|
+
return { threw: true, message: 'unknown inboundQueue fn ' + String(action.fn) };
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
return substitute(
|
|
399
|
+
{ results, events, files: snapshotFiles(dir) },
|
|
400
|
+
dir,
|
|
401
|
+
dirPlaceholder
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// --- Control quarantine -------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
function handleQuarantine(testCase) {
|
|
408
|
+
if (testCase.fn === 'paths') {
|
|
409
|
+
return {
|
|
410
|
+
store: outcome(() => withheldControlAckStorePath('$STATE', testCase.runnerId)),
|
|
411
|
+
quarantine: outcome(() => controlQuarantineFilePath('$STATE', testCase.runnerId)),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
const dir = scratch();
|
|
415
|
+
const storePath = join(dir, 'withheld-acks.json');
|
|
416
|
+
const quarantinePath = join(dir, 'quarantined-controls.jsonl');
|
|
417
|
+
if (testCase.initialStoreContents !== undefined) {
|
|
418
|
+
writeFileSync(storePath, testCase.initialStoreContents, 'utf8');
|
|
419
|
+
}
|
|
420
|
+
if (testCase.initialQuarantineContents !== undefined) {
|
|
421
|
+
writeFileSync(quarantinePath, testCase.initialQuarantineContents, 'utf8');
|
|
422
|
+
}
|
|
423
|
+
const events = [];
|
|
424
|
+
const emitted = [];
|
|
425
|
+
const log = collectingLog(events, dir);
|
|
426
|
+
const writeErrors = [];
|
|
427
|
+
const store = fileWithheldControlAckStore(storePath, (message) =>
|
|
428
|
+
writeErrors.push(message)
|
|
429
|
+
);
|
|
430
|
+
let epoch = testCase.epoch ?? undefined;
|
|
431
|
+
const quarantine = createControlQuarantine({
|
|
432
|
+
store,
|
|
433
|
+
quarantineFilePath: quarantinePath,
|
|
434
|
+
log,
|
|
435
|
+
emitRunnerEvent: (payload) => {
|
|
436
|
+
emitted.push(substitute(payload, dir, dirPlaceholder));
|
|
437
|
+
},
|
|
438
|
+
currentEpoch: () => epoch,
|
|
439
|
+
...(testCase.limit === undefined ? {} : { limit: testCase.limit }),
|
|
440
|
+
now: () => new Date(testCase.nowMs ?? 0),
|
|
441
|
+
});
|
|
442
|
+
const results = (testCase.rejections ?? []).map((rejection) => {
|
|
443
|
+
if (rejection.setEpoch !== undefined) {
|
|
444
|
+
epoch = rejection.setEpoch === null ? undefined : rejection.setEpoch;
|
|
445
|
+
}
|
|
446
|
+
return outcome(() =>
|
|
447
|
+
quarantine.onControlRejected(
|
|
448
|
+
rejection.control,
|
|
449
|
+
new Error(rejection.message ?? 'scripted rejection')
|
|
450
|
+
)
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
return substitute(
|
|
454
|
+
{ results, events, emitted, writeErrors, files: snapshotFiles(dir) },
|
|
455
|
+
dir,
|
|
456
|
+
dirPlaceholder
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// --- Outbox journal (recover-from-bytes) -----------------------------------------------------
|
|
461
|
+
|
|
462
|
+
function normalizeRecovered(recovered) {
|
|
463
|
+
return {
|
|
464
|
+
pendingEntries: recovered.pendingEntries,
|
|
465
|
+
kv: Object.fromEntries(recovered.kv),
|
|
466
|
+
counters: recovered.counters,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function handleOutboxJournal(testCase) {
|
|
471
|
+
if (testCase.fn === 'path') {
|
|
472
|
+
return outcome(() => outboxJournalPath('$PERSIST', testCase.threadKey));
|
|
473
|
+
}
|
|
474
|
+
// recover: fold the given journal bytes.
|
|
475
|
+
const dir = scratch();
|
|
476
|
+
const path = join(dir, 'thread.outbox.jsonl');
|
|
477
|
+
if (testCase.contents !== undefined) {
|
|
478
|
+
writeFileSync(path, testCase.contents, 'utf8');
|
|
479
|
+
}
|
|
480
|
+
const events = [];
|
|
481
|
+
const recovered = outcome(() =>
|
|
482
|
+
normalizeRecovered(recoverOutboxJournal(path, collectingLog(events, dir)))
|
|
483
|
+
);
|
|
484
|
+
return substitute({ recovered, events }, dir, dirPlaceholder);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// --- Outbox flow scenarios ---------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
function buildersFromSpec(spec, recordedReplies) {
|
|
490
|
+
const builders = {};
|
|
491
|
+
for (const [kind, config] of Object.entries(spec ?? {})) {
|
|
492
|
+
builders[kind] = {
|
|
493
|
+
build: (data) => {
|
|
494
|
+
if (config.mode === 'skip') {
|
|
495
|
+
return { skip: true, reason: String(data.skipReason ?? 'scripted-skip') };
|
|
496
|
+
}
|
|
497
|
+
if (config.mode === 'fail') {
|
|
498
|
+
throw new Error(String(data.failMessage ?? 'scripted-build-failure'));
|
|
499
|
+
}
|
|
500
|
+
return {
|
|
501
|
+
event: 'evt:' + kind,
|
|
502
|
+
payload: { ...data },
|
|
503
|
+
...(config.timeoutMs === undefined ? {} : { timeoutMs: config.timeoutMs }),
|
|
504
|
+
};
|
|
505
|
+
},
|
|
506
|
+
...(config.onReply === 'record'
|
|
507
|
+
? {
|
|
508
|
+
onReply: (data, reply) => {
|
|
509
|
+
recordedReplies.push({ kind, data, reply });
|
|
510
|
+
},
|
|
511
|
+
}
|
|
512
|
+
: {}),
|
|
513
|
+
...(config.coalesce === 'concat-bodies'
|
|
514
|
+
? {
|
|
515
|
+
coalesce: (older, newer) => ({
|
|
516
|
+
...newer,
|
|
517
|
+
body: String(older.body ?? '') + '|' + String(newer.body ?? ''),
|
|
518
|
+
}),
|
|
519
|
+
}
|
|
520
|
+
: {}),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
return builders;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function runOutboxPhase(phase, shared) {
|
|
527
|
+
// The outbox unrefs its retry timers by design (a pending backoff must
|
|
528
|
+
// never hold the runner process open), so a one-shot driver awaiting a
|
|
529
|
+
// flush across a backoff would see its event loop empty. Keep it alive
|
|
530
|
+
// for the phase.
|
|
531
|
+
const keepAlive = setInterval(() => {}, 25);
|
|
532
|
+
try {
|
|
533
|
+
return await runOutboxPhaseInner(phase, shared);
|
|
534
|
+
} finally {
|
|
535
|
+
clearInterval(keepAlive);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function runOutboxPhaseInner(phase, shared) {
|
|
540
|
+
const pushScript = [...(phase.pushScript ?? [])];
|
|
541
|
+
const pushes = [];
|
|
542
|
+
const permanentFailures = [];
|
|
543
|
+
const recordedReplies = [];
|
|
544
|
+
const events = [];
|
|
545
|
+
const log = collectingLog(events, shared.dir);
|
|
546
|
+
let nowMs = phase.nowMs ?? 1_000;
|
|
547
|
+
const outbox = createThreadOutbox({
|
|
548
|
+
threadKey: phase.threadKey ?? shared.threadKey,
|
|
549
|
+
push: async (event, payload, timeoutMs, onSent) => {
|
|
550
|
+
const step = pushScript.shift() ?? { kind: 'fail', message: 'push script exhausted' };
|
|
551
|
+
pushes.push({
|
|
552
|
+
event,
|
|
553
|
+
payload,
|
|
554
|
+
hasTimeout: timeoutMs !== undefined,
|
|
555
|
+
});
|
|
556
|
+
onSent?.();
|
|
557
|
+
if (step.delayMs !== undefined) {
|
|
558
|
+
await new Promise((resolve) => setTimeout(resolve, step.delayMs));
|
|
559
|
+
}
|
|
560
|
+
if (step.kind === 'ack') {
|
|
561
|
+
return step.reply ?? { ok: true };
|
|
562
|
+
}
|
|
563
|
+
const failure = new Error(String(step.message ?? 'scripted push failure'));
|
|
564
|
+
if (step.budgetExempt === true) {
|
|
565
|
+
failure.name = 'ScriptedBudgetExempt';
|
|
566
|
+
}
|
|
567
|
+
throw failure;
|
|
568
|
+
},
|
|
569
|
+
builders: buildersFromSpec(phase.builders ?? shared.builders, recordedReplies),
|
|
570
|
+
persistDir: shared.persistDir,
|
|
571
|
+
retryDelaysMs: phase.retryDelaysMs ?? [1, 1],
|
|
572
|
+
isRetryBudgetExempt: (error) => error.name === 'ScriptedBudgetExempt',
|
|
573
|
+
onPermanentFailure: (entry, error) => {
|
|
574
|
+
permanentFailures.push({
|
|
575
|
+
entrySeq: entry.entrySeq,
|
|
576
|
+
kind: entry.kind,
|
|
577
|
+
error: error.message,
|
|
578
|
+
});
|
|
579
|
+
},
|
|
580
|
+
log,
|
|
581
|
+
now: () => nowMs,
|
|
582
|
+
...(phase.maxInFlightPushes === undefined
|
|
583
|
+
? {}
|
|
584
|
+
: { maxInFlightPushes: phase.maxInFlightPushes }),
|
|
585
|
+
...(phase.journalCompactionThreshold === undefined
|
|
586
|
+
? {}
|
|
587
|
+
: { journalCompactionThreshold: phase.journalCompactionThreshold }),
|
|
588
|
+
...(phase.journalRecoveryMaxAgeMs === undefined
|
|
589
|
+
? {}
|
|
590
|
+
: { journalRecoveryMaxAgeMs: phase.journalRecoveryMaxAgeMs }),
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
const results = [];
|
|
594
|
+
for (const action of phase.actions ?? []) {
|
|
595
|
+
switch (action.fn) {
|
|
596
|
+
case 'advanceNow':
|
|
597
|
+
nowMs += action.byMs;
|
|
598
|
+
results.push({ defined: false });
|
|
599
|
+
break;
|
|
600
|
+
case 'enqueue':
|
|
601
|
+
results.push(
|
|
602
|
+
outcome(() =>
|
|
603
|
+
outbox.enqueue({
|
|
604
|
+
kind: action.kind,
|
|
605
|
+
data: action.data ?? {},
|
|
606
|
+
...(action.coalesceKey === undefined ? {} : { coalesceKey: action.coalesceKey }),
|
|
607
|
+
resolution: action.resolution ?? 'must_ack',
|
|
608
|
+
})
|
|
609
|
+
)
|
|
610
|
+
);
|
|
611
|
+
break;
|
|
612
|
+
case 'pause':
|
|
613
|
+
results.push(outcome(() => outbox.pause()));
|
|
614
|
+
break;
|
|
615
|
+
case 'resume':
|
|
616
|
+
results.push(outcome(() => outbox.resume()));
|
|
617
|
+
break;
|
|
618
|
+
case 'flush': {
|
|
619
|
+
try {
|
|
620
|
+
await outbox.flush(action.timeoutMs);
|
|
621
|
+
results.push({ flushed: true });
|
|
622
|
+
} catch (error) {
|
|
623
|
+
results.push({
|
|
624
|
+
flushed: false,
|
|
625
|
+
message: error instanceof Error ? error.message : String(error),
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
case 'metrics': {
|
|
631
|
+
const metrics = outbox.metrics();
|
|
632
|
+
results.push({
|
|
633
|
+
defined: true,
|
|
634
|
+
value: {
|
|
635
|
+
depth: metrics.depth,
|
|
636
|
+
totalEnqueued: metrics.totalEnqueued,
|
|
637
|
+
totalAcked: metrics.totalAcked,
|
|
638
|
+
totalRetried: metrics.totalRetried,
|
|
639
|
+
totalDroppedBestEffort: metrics.totalDroppedBestEffort,
|
|
640
|
+
totalPermanentFailures: metrics.totalPermanentFailures,
|
|
641
|
+
totalExpiredOnRecovery: metrics.totalExpiredOnRecovery,
|
|
642
|
+
journalDegraded: metrics.journalDegraded,
|
|
643
|
+
paused: metrics.paused,
|
|
644
|
+
ackedInWindow: metrics.pushLatency?.ackedInWindow ?? 0,
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
case 'journalSettled':
|
|
650
|
+
await outbox.journalSettled();
|
|
651
|
+
results.push({ defined: false });
|
|
652
|
+
break;
|
|
653
|
+
case 'rekeyJournal':
|
|
654
|
+
results.push(outcome(() => outbox.rekeyJournal(action.threadKey)));
|
|
655
|
+
await outbox.journalSettled();
|
|
656
|
+
break;
|
|
657
|
+
case 'sleep':
|
|
658
|
+
await new Promise((resolve) => setTimeout(resolve, action.ms));
|
|
659
|
+
results.push({ defined: false });
|
|
660
|
+
break;
|
|
661
|
+
case 'close':
|
|
662
|
+
await outbox.close(action.timeoutMs);
|
|
663
|
+
results.push({ defined: false });
|
|
664
|
+
break;
|
|
665
|
+
default:
|
|
666
|
+
results.push({ threw: true, message: 'unknown outbox fn ' + String(action.fn) });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// Make phase end deterministic: everything journaled so far is on disk.
|
|
670
|
+
await outbox.journalSettled();
|
|
671
|
+
return {
|
|
672
|
+
results,
|
|
673
|
+
pushes,
|
|
674
|
+
permanentFailures,
|
|
675
|
+
recordedReplies,
|
|
676
|
+
// Log EVENT NAMES in order (payloads carry timing values that legally
|
|
677
|
+
// differ across impls; names + counts are the stable contract).
|
|
678
|
+
logEvents: events.map((entry) => entry.event),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function handleOutboxScenario(testCase) {
|
|
683
|
+
const dir = scratch();
|
|
684
|
+
// Cross-impl seed: files the OTHER impl wrote (journal bytes), laid down
|
|
685
|
+
// before any phase so recovery reads foreign-written bytes.
|
|
686
|
+
for (const [relPath, contents] of Object.entries(testCase.initialFiles ?? {})) {
|
|
687
|
+
const full = join(dir, relPath);
|
|
688
|
+
mkdirSync(join(full, '..'), { recursive: true });
|
|
689
|
+
writeFileSync(full, contents, 'utf8');
|
|
690
|
+
}
|
|
691
|
+
const shared = {
|
|
692
|
+
dir,
|
|
693
|
+
persistDir: testCase.inMemory === true ? undefined : join(dir, 'commander-outbox'),
|
|
694
|
+
threadKey: testCase.threadKey ?? 'linzumi:42:thread-a',
|
|
695
|
+
builders: testCase.builders ?? {},
|
|
696
|
+
};
|
|
697
|
+
const phases = [];
|
|
698
|
+
for (const phase of testCase.phases ?? []) {
|
|
699
|
+
phases.push(await runOutboxPhase(phase, shared));
|
|
700
|
+
}
|
|
701
|
+
return substitute(
|
|
702
|
+
{ phases, files: snapshotFiles(dir) },
|
|
703
|
+
dir,
|
|
704
|
+
dirPlaceholder
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// --- Resume keystone flow scenarios ----------------------------------------------------------
|
|
709
|
+
|
|
710
|
+
async function handleKeystoneScenario(testCase) {
|
|
711
|
+
const dir = scratch();
|
|
712
|
+
const path = join(dir, 'thread.log');
|
|
713
|
+
if (testCase.initialContents !== undefined) {
|
|
714
|
+
writeFileSync(path, testCase.initialContents, 'utf8');
|
|
715
|
+
}
|
|
716
|
+
const events = [];
|
|
717
|
+
const log = collectingLog(events, dir);
|
|
718
|
+
const queue = createDurableInboundThreadQueue(path, log);
|
|
719
|
+
const storeCalls = [];
|
|
720
|
+
const store = watermarkMemStore(storeCalls);
|
|
721
|
+
if (typeof testCase.initialWatermark === 'number') {
|
|
722
|
+
store.onAdvance(testCase.watermarkKey, testCase.initialWatermark);
|
|
723
|
+
storeCalls.length = 0;
|
|
724
|
+
}
|
|
725
|
+
const lock = createThreadReconcilerLock();
|
|
726
|
+
const leases = createLeaseEpochRegistry();
|
|
727
|
+
const guard = createInFlightSourceSeqGuard();
|
|
728
|
+
const deadLetters = [];
|
|
729
|
+
const dispatchScript = [...(testCase.dispatchScript ?? [])];
|
|
730
|
+
const dispatchCalls = [];
|
|
731
|
+
const dispatch = async (sourceSeq, payload) => {
|
|
732
|
+
dispatchCalls.push({ sourceSeq, payload });
|
|
733
|
+
const step = dispatchScript.shift() ?? { outcome: 'ignored' };
|
|
734
|
+
return step.outcome;
|
|
735
|
+
};
|
|
736
|
+
const onDeadLetter = (args) => {
|
|
737
|
+
deadLetters.push({
|
|
738
|
+
threadId: args.threadId,
|
|
739
|
+
sourceSeq: args.sourceSeq,
|
|
740
|
+
reason: args.reason,
|
|
741
|
+
attempts: args.attempts,
|
|
742
|
+
alreadyPublished: args.alreadyPublished,
|
|
743
|
+
});
|
|
744
|
+
};
|
|
745
|
+
const results = [];
|
|
746
|
+
for (const step of testCase.steps ?? []) {
|
|
747
|
+
if (step.fn === 'receive') {
|
|
748
|
+
const outcomeValue = await receiveResumeMessage({
|
|
749
|
+
queue,
|
|
750
|
+
store,
|
|
751
|
+
lock,
|
|
752
|
+
leases,
|
|
753
|
+
threadId: testCase.threadId,
|
|
754
|
+
watermarkKey: testCase.watermarkKey,
|
|
755
|
+
sourceSeq: step.sourceSeq,
|
|
756
|
+
kind: step.kind ?? 'live',
|
|
757
|
+
payload: step.payload ?? {},
|
|
758
|
+
leaseEpoch: step.leaseEpoch ?? undefined,
|
|
759
|
+
dispatch,
|
|
760
|
+
serverRows: step.serverRows ?? [],
|
|
761
|
+
log,
|
|
762
|
+
nowMs: step.nowMs ?? 1_000,
|
|
763
|
+
...(step.policy === undefined ? {} : { policy: step.policy }),
|
|
764
|
+
onlyDispatchUnattempted: step.onlyDispatchUnattempted ?? false,
|
|
765
|
+
onDeadLetter,
|
|
766
|
+
inFlightGuard: guard,
|
|
767
|
+
});
|
|
768
|
+
results.push({ receive: outcomeValue });
|
|
769
|
+
} else if (step.fn === 'drain') {
|
|
770
|
+
const dispatched = await drainResumeQueue({
|
|
771
|
+
queue,
|
|
772
|
+
store,
|
|
773
|
+
lock,
|
|
774
|
+
threadId: testCase.threadId,
|
|
775
|
+
watermarkKey: testCase.watermarkKey,
|
|
776
|
+
serverRows: step.serverRows ?? [],
|
|
777
|
+
dispatch,
|
|
778
|
+
log,
|
|
779
|
+
nowMs: step.nowMs ?? 1_000,
|
|
780
|
+
...(step.policy === undefined ? {} : { policy: step.policy }),
|
|
781
|
+
onlyDispatchUnattempted: step.onlyDispatchUnattempted ?? false,
|
|
782
|
+
onTornTailUnrecoverable: (threadId) => {
|
|
783
|
+
deadLetters.push({ threadId, tornTailSurfaced: true });
|
|
784
|
+
},
|
|
785
|
+
onDeadLetter,
|
|
786
|
+
inFlightGuard: guard,
|
|
787
|
+
});
|
|
788
|
+
results.push({ drained: dispatched });
|
|
789
|
+
} else if (step.fn === 'executionCommit') {
|
|
790
|
+
// The turn EXECUTED to a terminal: the live path commits the floor
|
|
791
|
+
// (I58 execution-confirm) and clears the in-flight guard.
|
|
792
|
+
const advanced = commitLiveAcceptedSourceSeq({
|
|
793
|
+
store,
|
|
794
|
+
watermarkKey: testCase.watermarkKey,
|
|
795
|
+
sourceSeq: step.sourceSeq,
|
|
796
|
+
});
|
|
797
|
+
guard.clearSeq(testCase.watermarkKey, step.sourceSeq);
|
|
798
|
+
results.push({ executionCommit: advanced });
|
|
799
|
+
} else if (step.fn === 'workerDeath') {
|
|
800
|
+
guard.clearThread(testCase.watermarkKey);
|
|
801
|
+
results.push({ workerDeath: true });
|
|
802
|
+
} else if (step.fn === 'applyOutcome') {
|
|
803
|
+
await applyResumeDispatchOutcome({
|
|
804
|
+
queue,
|
|
805
|
+
store,
|
|
806
|
+
watermarkKey: testCase.watermarkKey,
|
|
807
|
+
sourceSeq: step.sourceSeq,
|
|
808
|
+
threadId: testCase.threadId,
|
|
809
|
+
outcome: step.outcome,
|
|
810
|
+
log,
|
|
811
|
+
nowMs: step.nowMs ?? 1_000,
|
|
812
|
+
...(step.policy === undefined ? {} : { policy: step.policy }),
|
|
813
|
+
onDeadLetter,
|
|
814
|
+
inFlightGuard: guard,
|
|
815
|
+
});
|
|
816
|
+
results.push({ applied: step.outcome });
|
|
817
|
+
} else {
|
|
818
|
+
results.push({ threw: true, message: 'unknown keystone fn ' + String(step.fn) });
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return substitute(
|
|
822
|
+
{
|
|
823
|
+
results,
|
|
824
|
+
dispatchCalls,
|
|
825
|
+
deadLetters,
|
|
826
|
+
storeCalls,
|
|
827
|
+
store: store.dump(),
|
|
828
|
+
liveEntries: queue.entries().map((entry) => ({ ...entry })),
|
|
829
|
+
ackedEntries: queue.ackedEntries().map((entry) => ({ ...entry })),
|
|
830
|
+
logEvents: events.map((entry) => entry.event),
|
|
831
|
+
files: snapshotFiles(dir),
|
|
832
|
+
},
|
|
833
|
+
dir,
|
|
834
|
+
dirPlaceholder
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// --- Dispatch --------------------------------------------------------------------------------
|
|
839
|
+
|
|
840
|
+
async function handle(testCase) {
|
|
841
|
+
switch (testCase.op) {
|
|
842
|
+
case 'queuedMessage':
|
|
843
|
+
return handleQueuedMessage(testCase);
|
|
844
|
+
case 'pendingQueue':
|
|
845
|
+
return handlePendingQueue(testCase);
|
|
846
|
+
case 'watermark':
|
|
847
|
+
return handleWatermark(testCase);
|
|
848
|
+
case 'inboundQueue':
|
|
849
|
+
return handleInboundQueue(testCase);
|
|
850
|
+
case 'quarantine':
|
|
851
|
+
return handleQuarantine(testCase);
|
|
852
|
+
case 'outboxJournal':
|
|
853
|
+
return handleOutboxJournal(testCase);
|
|
854
|
+
case 'outboxScenario':
|
|
855
|
+
return await handleOutboxScenario(testCase);
|
|
856
|
+
case 'keystoneScenario':
|
|
857
|
+
return await handleKeystoneScenario(testCase);
|
|
858
|
+
default:
|
|
859
|
+
return { threw: true, message: 'unknown op ' + String(testCase.op) };
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
let stdin = '';
|
|
864
|
+
process.stdin.setEncoding('utf8');
|
|
865
|
+
for await (const chunk of process.stdin) {
|
|
866
|
+
stdin += chunk;
|
|
867
|
+
}
|
|
868
|
+
const cases = JSON.parse(stdin);
|
|
869
|
+
const results = [];
|
|
870
|
+
for (const testCase of cases) {
|
|
871
|
+
results.push(await handle(testCase));
|
|
872
|
+
}
|
|
873
|
+
// Exit only after stdout is fully flushed: scripted outbox scenarios can
|
|
874
|
+
// leave unref'd timers alive, so the driver exits explicitly - but a bare
|
|
875
|
+
// process.exit(0) truncates large result arrays mid-pipe.
|
|
876
|
+
process.stdout.write(JSON.stringify(results), () => {
|
|
877
|
+
process.exit(0);
|
|
878
|
+
});
|
|
879
|
+
`;
|
|
880
|
+
|
|
881
|
+
await mkdir(join(packageRoot, '.differential'), { recursive: true });
|
|
882
|
+
|
|
883
|
+
await build({
|
|
884
|
+
stdin: {
|
|
885
|
+
contents: driverSource,
|
|
886
|
+
resolveDir: packageRoot,
|
|
887
|
+
sourcefile: 'queue-parity-driver.ts',
|
|
888
|
+
loader: 'ts',
|
|
889
|
+
},
|
|
890
|
+
bundle: true,
|
|
891
|
+
platform: 'node',
|
|
892
|
+
target: 'node20',
|
|
893
|
+
format: 'esm',
|
|
894
|
+
outfile: join(packageRoot, '.differential/queue-parity-oracle.mjs'),
|
|
895
|
+
minify: false,
|
|
896
|
+
sourcemap: false,
|
|
897
|
+
legalComments: 'none',
|
|
898
|
+
external: ['ws', 'undici', 'blessed', '@anthropic-ai/claude-agent-sdk'],
|
|
899
|
+
});
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// publishes @linzumi/cli@<new-version> to npm.
|
|
13
13
|
//
|
|
14
14
|
// The package version is pinned in several places (package.json,
|
|
15
|
-
// src/version.ts, README.md, and version-pinning tests). This script rewrites
|
|
15
|
+
// src/version.ts, README.md, the public alias manifest, and version-pinning tests). This script rewrites
|
|
16
16
|
// every known site, FAILS LOUDLY if any expected pattern is missing, and then
|
|
17
17
|
// scans package.json/src/README/test/scripts/bin for any leftover
|
|
18
18
|
// old-version string - so a future, not-yet-listed pin site cannot silently
|
|
@@ -71,6 +71,7 @@ if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$/.test(newVersion)) {
|
|
|
71
71
|
|
|
72
72
|
const packageRoot =
|
|
73
73
|
explicitRoot ?? dirname(dirname(fileURLToPath(import.meta.url)));
|
|
74
|
+
const aliasPackageRoot = join(dirname(packageRoot), 'linzumi');
|
|
74
75
|
|
|
75
76
|
let manifest;
|
|
76
77
|
|
|
@@ -96,6 +97,24 @@ if (oldVersion === newVersion) {
|
|
|
96
97
|
// the bump aborts; add new entries here when a new pin site appears (the
|
|
97
98
|
// leftover scan below is what tells you one appeared).
|
|
98
99
|
const knownSites = [
|
|
100
|
+
{
|
|
101
|
+
file: '../linzumi/package.json',
|
|
102
|
+
description: 'public alias manifest version',
|
|
103
|
+
pattern: `"version": "${oldVersion}"`,
|
|
104
|
+
replacement: `"version": "${newVersion}"`,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
file: '../linzumi/package.json',
|
|
108
|
+
description: 'public alias exact canonical dependency',
|
|
109
|
+
pattern: `"@linzumi/cli": "${oldVersion}"`,
|
|
110
|
+
replacement: `"@linzumi/cli": "${newVersion}"`,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
file: '../../pnpm-lock.yaml',
|
|
114
|
+
description: 'public alias lockfile dependency specifier',
|
|
115
|
+
pattern: `specifier: ${oldVersion}\n version: link:../linzumi-cli`,
|
|
116
|
+
replacement: `specifier: ${newVersion}\n version: link:../linzumi-cli`,
|
|
117
|
+
},
|
|
99
118
|
{
|
|
100
119
|
file: 'package.json',
|
|
101
120
|
description: 'npm package manifest version',
|
|
@@ -209,6 +228,11 @@ function scanTargets() {
|
|
|
209
228
|
const roots = ['src', 'test', 'scripts', 'bin'];
|
|
210
229
|
const files = ['package.json', 'README.md'];
|
|
211
230
|
|
|
231
|
+
// Spec: plans/2026-07-12-linzumi-unscoped-npm-package-spec.md
|
|
232
|
+
// The alias version and canonical dependency are one release invariant.
|
|
233
|
+
files.push(relative(packageRoot, join(aliasPackageRoot, 'package.json')));
|
|
234
|
+
files.push('../../pnpm-lock.yaml');
|
|
235
|
+
|
|
212
236
|
for (const root of roots) {
|
|
213
237
|
const rootPath = join(packageRoot, root);
|
|
214
238
|
let entries;
|