@dzhechkov/harness-core 0.8.36 → 0.8.38
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/.dz-manifest.json +216 -76
- package/README.md +349 -8
- package/dist/agentdb-index.d.ts +87 -7
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +416 -57
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +19 -1
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +187 -36
- package/dist/apply-leg.js.map +1 -1
- package/dist/codex-rollouts.d.ts +118 -0
- package/dist/codex-rollouts.d.ts.map +1 -0
- package/dist/codex-rollouts.js +297 -0
- package/dist/codex-rollouts.js.map +1 -0
- package/dist/cost-ledger.d.ts +56 -4
- package/dist/cost-ledger.d.ts.map +1 -1
- package/dist/cost-ledger.js +176 -20
- package/dist/cost-ledger.js.map +1 -1
- package/dist/cross-family-control.d.ts +380 -0
- package/dist/cross-family-control.d.ts.map +1 -0
- package/dist/cross-family-control.js +848 -0
- package/dist/cross-family-control.js.map +1 -0
- package/dist/debt-ratchet.d.ts +53 -0
- package/dist/debt-ratchet.d.ts.map +1 -0
- package/dist/debt-ratchet.js +107 -0
- package/dist/debt-ratchet.js.map +1 -0
- package/dist/embedding-config.d.ts +42 -0
- package/dist/embedding-config.d.ts.map +1 -1
- package/dist/embedding-config.js +106 -10
- package/dist/embedding-config.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +6 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +29 -0
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-decision-recall.d.ts +2 -2
- package/dist/feature-adr-decision-recall.d.ts.map +1 -1
- package/dist/feature-adr-decision-recall.js +5 -3
- package/dist/feature-adr-decision-recall.js.map +1 -1
- package/dist/feature-adr-envelope.d.ts +96 -0
- package/dist/feature-adr-envelope.d.ts.map +1 -0
- package/dist/feature-adr-envelope.js +183 -0
- package/dist/feature-adr-envelope.js.map +1 -0
- package/dist/feature-adr-routing.d.ts +64 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +133 -3
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/feature-adr-stage-canon.d.ts +79 -0
- package/dist/feature-adr-stage-canon.d.ts.map +1 -0
- package/dist/feature-adr-stage-canon.js +117 -0
- package/dist/feature-adr-stage-canon.js.map +1 -0
- package/dist/index.d.ts +21 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -5
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +4 -4
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/mutation-gate.d.ts +51 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +295 -0
- package/dist/mutation-gate.js.map +1 -1
- package/dist/qe-bridge.d.ts +8 -0
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +4 -2
- package/dist/qe-bridge.js.map +1 -1
- package/dist/qe-findings.d.ts +107 -0
- package/dist/qe-findings.d.ts.map +1 -0
- package/dist/qe-findings.js +417 -0
- package/dist/qe-findings.js.map +1 -0
- package/dist/recap.d.ts +1 -1
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +4 -2
- package/dist/recap.js.map +1 -1
- package/dist/review-cost.d.ts +51 -0
- package/dist/review-cost.d.ts.map +1 -0
- package/dist/review-cost.js +110 -0
- package/dist/review-cost.js.map +1 -0
- package/dist/round.d.ts +207 -1
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +321 -4
- package/dist/round.js.map +1 -1
- package/dist/run-records.d.ts +97 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +336 -2
- package/dist/run-records.js.map +1 -1
- package/dist/score.d.ts +44 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +78 -5
- package/dist/score.js.map +1 -1
- package/package.json +1 -1
- package/sbom.json +425 -75
- package/src/agentdb-index.ts +423 -60
- package/src/apply-leg.ts +187 -36
- package/src/codex-rollouts.ts +374 -0
- package/src/cost-ledger.ts +232 -24
- package/src/cross-family-control.ts +1038 -0
- package/src/debt-ratchet.ts +143 -0
- package/src/embedding-config.ts +131 -10
- package/src/feature-adr-checkpoints.ts +29 -0
- package/src/feature-adr-decision-recall.ts +6 -4
- package/src/feature-adr-envelope.ts +242 -0
- package/src/feature-adr-routing.ts +150 -3
- package/src/feature-adr-stage-canon.ts +141 -0
- package/src/index.ts +65 -6
- package/src/loop-blobs.generated.ts +4 -4
- package/src/mutation-gate.ts +316 -0
- package/src/qe-bridge.ts +12 -2
- package/src/qe-findings.ts +463 -0
- package/src/recap.ts +10 -3
- package/src/review-cost.ts +139 -0
- package/src/round.ts +481 -6
- package/src/run-records.ts +388 -2
- package/src/score.ts +115 -6
package/src/apply-leg.ts
CHANGED
|
@@ -190,8 +190,26 @@ export function probeRecallEngine(socketPath: string, timeoutMs = 1000): Promise
|
|
|
190
190
|
* the cosine-fallback `scored` array are filtered to exclude `domain === 'apply-leg-probe'` unless
|
|
191
191
|
* the request carried `probe: true` — a probe's own beacon still needs to reach ITS query, only a
|
|
192
192
|
* REAL prompt must never see it.
|
|
193
|
+
*
|
|
194
|
+
* Bumped 10→11 (feature `embed-daemon-memory`, ADR-001 D1): the daemon now tries core's OWN cached
|
|
195
|
+
* embedder (`resolveAgentdbEmbedder`) first — one pipeline per process — building a private one only
|
|
196
|
+
* when core cannot hand one over; `ready` gained the ` embedder=<core-shared|own-fallback>` suffix
|
|
197
|
+
* (see `features/embed-daemon-memory/07_code_changes/change_manifest.md` for the full T3 note; this
|
|
198
|
+
* paragraph was missing from the bump history and is added here for the record, fix round 1).
|
|
199
|
+
*
|
|
200
|
+
* Bumped 11→12 (`embed-daemon-memory`, fix round 1 — F1/F2, Codex #1b/#3): (a) F1 — the own-fallback
|
|
201
|
+
* pipeline now reads the STORE's own dtype manifest before building (absent = fp32 legacy, a
|
|
202
|
+
* present-but-unrecognized value fails the request loudly instead of silently building fp32 against
|
|
203
|
+
* a q8 store); `ready`'s own-fallback branch gains ` dtype=<fp32|q8|error>`. (b) F2 — a core-shared
|
|
204
|
+
* STARTUP init failure no longer falls back to building an own pipeline (which risked a second live
|
|
205
|
+
* embedder once a later hybrid call succeeded): `embedderSource` stays `'core-shared'` and `embed`
|
|
206
|
+
* re-resolves `core.resolveAgentdbEmbedder` per request instead; `ready` names a startup failure
|
|
207
|
+
* inline as `core-shared (init failed: <msg>, will retry per request)`; `warmUpHybridEngine()` is
|
|
208
|
+
* skipped entirely in own-fallback mode. (c) `answerRecall`'s `embed(prompt)` call is now wrapped so
|
|
209
|
+
* either failure mode degrades to the SAME honest `cosine-fallback` reply shape as every other
|
|
210
|
+
* failure, never a bare protocol `{error}`.
|
|
193
211
|
*/
|
|
194
|
-
export const APPLY_LEG_VERSION =
|
|
212
|
+
export const APPLY_LEG_VERSION = 12;
|
|
195
213
|
|
|
196
214
|
/**
|
|
197
215
|
* Parse the `dz-apply-leg-version` stamp from a deployed helper file. Unlike
|
|
@@ -1027,6 +1045,18 @@ function socketAlive(path) {
|
|
|
1027
1045
|
});
|
|
1028
1046
|
}
|
|
1029
1047
|
|
|
1048
|
+
// ADR-001 (embed-daemon-memory, D1/T3): better-sqlite3 is resolved INDEPENDENTLY of the embedder now
|
|
1049
|
+
// — the cosine-fallback leg always needs it to read the mirror, regardless of whether the embedder
|
|
1050
|
+
// itself comes from core (embedder=core-shared) or this daemon's own pipeline (embedder=own-fallback).
|
|
1051
|
+
// Splitting it out of the old resolveDeps() lets the daemon start on core-shared alone when only
|
|
1052
|
+
// better-sqlite3 (not transformers) is locally resolvable.
|
|
1053
|
+
function resolveDatabase() {
|
|
1054
|
+
const req = createRequire(join(PROJECT, 'package.json'));
|
|
1055
|
+
return req('better-sqlite3');
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// own-fallback ONLY (ADR-001 D1): resolves the transformers module for the daemon's OWN pipeline,
|
|
1059
|
+
// built only when core has no resolveAgentdbEmbedder to share (no core module, or it errored).
|
|
1030
1060
|
function resolveDeps() {
|
|
1031
1061
|
const req = createRequire(join(PROJECT, 'package.json'));
|
|
1032
1062
|
// agentdb >= 3.0.0-alpha depends on '@huggingface/transformers' (the '@xenova/transformers'
|
|
@@ -1054,7 +1084,7 @@ function resolveDeps() {
|
|
|
1054
1084
|
if (transformers === undefined) {
|
|
1055
1085
|
throw new Error('neither @huggingface/transformers nor @xenova/transformers could be resolved (' + String((lastErr && lastErr.message) || lastErr) + ')');
|
|
1056
1086
|
}
|
|
1057
|
-
return {
|
|
1087
|
+
return { transformers };
|
|
1058
1088
|
}
|
|
1059
1089
|
|
|
1060
1090
|
const cos = (a, b) => {
|
|
@@ -1119,17 +1149,151 @@ async function main() {
|
|
|
1119
1149
|
|
|
1120
1150
|
const started = Date.now();
|
|
1121
1151
|
const model = configuredModel();
|
|
1122
|
-
|
|
1152
|
+
|
|
1153
|
+
// better-sqlite3 is required regardless of which embedder answers a request (ADR-001, T3) — the
|
|
1154
|
+
// cosine-fallback leg always reads the mirror through it.
|
|
1155
|
+
let Database;
|
|
1123
1156
|
try {
|
|
1124
|
-
|
|
1157
|
+
Database = resolveDatabase();
|
|
1125
1158
|
} catch (err) {
|
|
1126
1159
|
log('deps unavailable — not starting:', err?.message ?? err);
|
|
1127
1160
|
process.exit(0); // never a hard failure: the hook degrades to silence
|
|
1128
1161
|
}
|
|
1129
1162
|
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1163
|
+
// ADR-001 D1 (embed-daemon-memory): the daemon SHARES core's single per-process embedder and
|
|
1164
|
+
// builds a private pipeline only when core cannot hand one over AT ALL — no core module, or a core
|
|
1165
|
+
// build too old to export \`resolveAgentdbEmbedder\` (F2, fix round 1, Codex #3). A STARTUP init
|
|
1166
|
+
// FAILURE (core exists, has the export, but the call itself errored — offline model cache, a
|
|
1167
|
+
// corrupted manifest) is NEVER treated as "core unavailable": own-fallback must not be built in
|
|
1168
|
+
// that case, because a LATER hybrid call could succeed and stand up a SECOND, independent pipeline
|
|
1169
|
+
// in the same process — the exact "two live embedders" defect the review named. Instead
|
|
1170
|
+
// \`embedderSource\` STAYS \`'core-shared'\` and \`embed\` re-resolves \`core.resolveAgentdbEmbedder\`
|
|
1171
|
+
// on EVERY call: core's own per-process cache (agentdb-index.ts) makes a repeat call after SUCCESS
|
|
1172
|
+
// effectively free, and evicts its own entry on failure — so a transient startup failure can heal
|
|
1173
|
+
// on a later request without this daemon ever building an embedder of its own.
|
|
1174
|
+
//
|
|
1175
|
+
// The shared embedder is still PROBED EAGERLY, before \`ready\`, for the same reason the own
|
|
1176
|
+
// pipeline always was: the cosine-fallback leg must answer inside the hook budget on the FIRST
|
|
1177
|
+
// request too (parity AC-2: hybrid delayed by 3000 ms, reply < 2000 ms). MEASURED 2026-09-16
|
|
1178
|
+
// (change_manifest.md, deviation B): a LAZY resolve put the cold pipeline init (2000-3600 ms) on
|
|
1179
|
+
// the first fallback reply — 4/5 runs at 2063-2146 ms. The probe's OUTCOME only decides the
|
|
1180
|
+
// \`ready\`-log wording now (F2) — it never gates \`embedderSource\` or builds an own-fallback
|
|
1181
|
+
// pipeline. The starved-budget parity test that used to lean on a cold engine now injects
|
|
1182
|
+
// DZ_EMBED_HYBRID_DELAY_MS itself, so its premise holds by construction, not by cold timing.
|
|
1183
|
+
let embed;
|
|
1184
|
+
let embedderSource;
|
|
1185
|
+
let embedderReadyDetail = '';
|
|
1186
|
+
let ownFallbackDtypeLabel;
|
|
1187
|
+
const startupCore = await loadCoreModule();
|
|
1188
|
+
const coreHasSharedEmbedder = startupCore !== undefined && typeof startupCore.resolveAgentdbEmbedder === 'function';
|
|
1189
|
+
if (coreHasSharedEmbedder) {
|
|
1190
|
+
embedderSource = 'core-shared';
|
|
1191
|
+
embed = async (text) => {
|
|
1192
|
+
const shared = await startupCore.resolveAgentdbEmbedder(PROJECT);
|
|
1193
|
+
if (!shared || typeof shared.embed !== 'function' || 'error' in shared) {
|
|
1194
|
+
throw new Error((shared && shared.error) || 'resolveAgentdbEmbedder returned no embed()');
|
|
1195
|
+
}
|
|
1196
|
+
return shared.embed(text);
|
|
1197
|
+
};
|
|
1198
|
+
let startupProbe;
|
|
1199
|
+
try {
|
|
1200
|
+
startupProbe = await startupCore.resolveAgentdbEmbedder(PROJECT);
|
|
1201
|
+
} catch (err) {
|
|
1202
|
+
startupProbe = { error: err?.message ?? String(err) };
|
|
1203
|
+
}
|
|
1204
|
+
if (!startupProbe || typeof startupProbe.embed !== 'function' || 'error' in startupProbe) {
|
|
1205
|
+
const msg = (startupProbe && startupProbe.error) || 'resolveAgentdbEmbedder returned no embed()';
|
|
1206
|
+
log('core-shared embedder init failed at startup, will retry per request:', msg);
|
|
1207
|
+
embedderReadyDetail = \` (init failed: \${msg}, will retry per request)\`;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
if (embed === undefined) {
|
|
1211
|
+
// own-fallback: ONLY when core has no resolveAgentdbEmbedder to share at all (no core module, or
|
|
1212
|
+
// a fake/old core, as in test fixtures) — never as a reaction to a startup init error (above).
|
|
1213
|
+
let deps;
|
|
1214
|
+
try {
|
|
1215
|
+
deps = resolveDeps();
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
log('deps unavailable — not starting:', err?.message ?? err);
|
|
1218
|
+
process.exit(0); // never a hard failure: the hook degrades to silence
|
|
1219
|
+
}
|
|
1220
|
+
// F1 (fix round 1, Codex #1b): the daemon has no core to ask, so it reads the STORE's own dtype
|
|
1221
|
+
// manifest directly — an own pipeline built blindly at fp32 would silently compare vectors from
|
|
1222
|
+
// two different spaces against a q8 store. Absent manifest = fp32 (legacy — matches every store
|
|
1223
|
+
// predating this feature, same discipline as embedding-config.ts's readEmbedManifest). A manifest
|
|
1224
|
+
// that IS present but names neither known dtype is NEVER folded into that same fp32 case — this
|
|
1225
|
+
// daemon fails loudly for that request (embed() throws below, caught honestly by answerRecall)
|
|
1226
|
+
// rather than silently mixing dtype spaces.
|
|
1227
|
+
const manifestPath = join(PROJECT, '.dz', 'agentdb.db.embed-manifest.json');
|
|
1228
|
+
let ownDtype = 'fp32';
|
|
1229
|
+
let ownDtypeError;
|
|
1230
|
+
let manifestIsFile = false;
|
|
1231
|
+
try {
|
|
1232
|
+
manifestIsFile = statSync(manifestPath).isFile(); // a non-file at the sidecar path is "absent", as in core
|
|
1233
|
+
} catch {
|
|
1234
|
+
manifestIsFile = false;
|
|
1235
|
+
}
|
|
1236
|
+
if (manifestIsFile) {
|
|
1237
|
+
try {
|
|
1238
|
+
const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'))?.dtype;
|
|
1239
|
+
if (raw === 'fp32' || raw === 'q8') {
|
|
1240
|
+
ownDtype = raw;
|
|
1241
|
+
} else if (raw !== undefined) {
|
|
1242
|
+
ownDtypeError = String(raw);
|
|
1243
|
+
}
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
// Lead delta after Codex r2 (HIGH): a manifest that EXISTS but cannot be parsed is NOT
|
|
1246
|
+
// "absent" — refusing beats guessing fp32 over a q8 store (same rule as core's readError).
|
|
1247
|
+
ownDtypeError = 'unreadable manifest: ' + (err?.message ?? String(err));
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
embedderSource = 'own-fallback';
|
|
1251
|
+
if (ownDtypeError !== undefined) {
|
|
1252
|
+
log('manifest dtype unknown:', ownDtypeError);
|
|
1253
|
+
ownFallbackDtypeLabel = 'error';
|
|
1254
|
+
const dtypeErrMsg = \`manifest dtype unknown: \${ownDtypeError}\`;
|
|
1255
|
+
embed = async () => { throw new Error(dtypeErrMsg); };
|
|
1256
|
+
} else {
|
|
1257
|
+
ownFallbackDtypeLabel = ownDtype;
|
|
1258
|
+
const { pipeline } = await import(deps.transformers);
|
|
1259
|
+
const extractor = await pipeline('feature-extraction', model, ownDtype === 'q8' ? { dtype: 'q8' } : {});
|
|
1260
|
+
embed = async (text) => Array.from((await extractor(text, { pooling: 'mean', normalize: true })).data);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// AM-1 (fix round 1): warm resolveAgentdbEmbedder — cached PER PROCESS since db1521ba (cold
|
|
1265
|
+
// ~2-3.6 s, warm ~1 ms, MEASURED, see the manifest's T8/AM-1 discussion) — OFF the request path,
|
|
1266
|
+
// so the first REAL \`op: recall\` is not the one that pays the cold init. Fired fire-and-forget as
|
|
1267
|
+
// early as main() can (moved up from right-before-listen, T3/embed-daemon-memory: every ms of
|
|
1268
|
+
// extra head start matters against a multi-second cold cost — see change_manifest.md), never
|
|
1269
|
+
// awaited by startup: this is a best-effort head start, not a guarantee — a request landing in
|
|
1270
|
+
// the window before it completes still pays the (possibly-partial, since it JOINS the same
|
|
1271
|
+
// in-flight resolveAgentdbEmbedder promise, D1) cold cost, and a warm-up failure (no core module,
|
|
1272
|
+
// engine error) is silently swallowed — never-block applies to startup exactly as it does to a
|
|
1273
|
+
// request. Measured: the slowest cold resolveAgentdbEmbedder init observed in this environment
|
|
1274
|
+
// was 3653 ms (T8 log, 2026-09-14) — 10 s leaves a wide margin without risking an unbounded
|
|
1275
|
+
// warm-up hang.
|
|
1276
|
+
const WARMUP_TIMEOUT_MS = 10_000;
|
|
1277
|
+
async function warmUpHybridEngine() {
|
|
1278
|
+
const core = await loadCoreModule();
|
|
1279
|
+
if (core === undefined) return;
|
|
1280
|
+
const guard = new Promise((resolve) => {
|
|
1281
|
+
const t = setTimeout(resolve, WARMUP_TIMEOUT_MS);
|
|
1282
|
+
t.unref?.();
|
|
1283
|
+
});
|
|
1284
|
+
// An empty-string query still exercises the FULL semantic leg (embed + engine.search), which is
|
|
1285
|
+
// exactly what needs warming; recallHybrid degrades any error inside it honestly, so nothing
|
|
1286
|
+
// here needs its own try/catch beyond the outer .catch(() => {}) at the call site below.
|
|
1287
|
+
await Promise.race([core.recallHybrid(PROJECT, '', { limit: 1, mode: 'hook', deferExposures: true }), guard]);
|
|
1288
|
+
}
|
|
1289
|
+
// Fire-and-forget, never awaited — main() proceeds immediately regardless of warm-up outcome.
|
|
1290
|
+
// F2 (fix round 1, Codex #3): warm-up specifically primes the HYBRID leg's own use of
|
|
1291
|
+
// resolveAgentdbEmbedder — pointless when this daemon has no core embedder to share (own-fallback
|
|
1292
|
+
// has no core module, or a fake/old core in tests) AND risks standing up a SECOND, unrelated
|
|
1293
|
+
// pipeline via whatever recallHybrid does internally in that case. Skipped entirely in own-fallback.
|
|
1294
|
+
if (embedderSource === 'core-shared') {
|
|
1295
|
+
warmUpHybridEngine().catch(() => {});
|
|
1296
|
+
}
|
|
1133
1297
|
|
|
1134
1298
|
// READ-ONLY. This process must never be the writer that tears the file for a concurrent reader.
|
|
1135
1299
|
const dbPath = join(PROJECT, '.dz', 'agentdb.db');
|
|
@@ -1137,7 +1301,7 @@ async function main() {
|
|
|
1137
1301
|
|
|
1138
1302
|
function loadPatterns() {
|
|
1139
1303
|
if (!existsSync(dbPath)) return [];
|
|
1140
|
-
const db = new
|
|
1304
|
+
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
1141
1305
|
try {
|
|
1142
1306
|
const ph = DZ_TASK_TYPES.map(() => '?').join(',');
|
|
1143
1307
|
// NOTE: the vector mirror carries no \`domain\` — that column lives in the lexical store. An
|
|
@@ -1249,36 +1413,22 @@ async function main() {
|
|
|
1249
1413
|
patternsAt = Date.now();
|
|
1250
1414
|
}
|
|
1251
1415
|
if (patterns.length === 0) return { hits: [], engine: 'cosine-fallback', reason: hybrid.reason };
|
|
1252
|
-
|
|
1416
|
+
// F1/F2 (fix round 1): \`embed()\` can now THROW honestly (own-fallback with an unrecognized
|
|
1417
|
+
// manifest dtype, F1; core-shared re-resolution failing on this exact request, F2) — caught here
|
|
1418
|
+
// so it degrades to the SAME honest cosine-fallback shape every other failure gets, never a bare
|
|
1419
|
+
// protocol {error} reply reaching the socket handler's outer catch (the AM-3 defect class).
|
|
1420
|
+
let qv;
|
|
1421
|
+
try {
|
|
1422
|
+
qv = await embed(prompt);
|
|
1423
|
+
} catch (err) {
|
|
1424
|
+
return { hits: [], engine: 'cosine-fallback', reason: \`embedder unavailable: \${err?.message ?? err}\` };
|
|
1425
|
+
}
|
|
1253
1426
|
const scored = patterns.map((p) => ({ dzId: p.dzId, pattern: p.pattern, score: cos(qv, p.vec), domain: p.domain, ...(p.quarantined ? { quarantined: true } : {}) }));
|
|
1254
1427
|
scored.sort((a, b) => b.score - a.score);
|
|
1255
1428
|
// AM-3: same domain exclusion as the hybrid leg, applied before slicing for the same reason.
|
|
1256
1429
|
return { hits: filterProbeHits(scored, probe).slice(0, limit), engine: 'cosine-fallback', reason: hybrid.reason };
|
|
1257
1430
|
}
|
|
1258
1431
|
|
|
1259
|
-
// AM-1 (fix round 1): warm resolveAgentdbEmbedder — cached PER PROCESS since db1521ba (cold
|
|
1260
|
-
// ~2-3.6 s, warm ~1 ms, MEASURED, see the manifest's T8/AM-1 discussion) — OFF the request path,
|
|
1261
|
-
// so the first REAL \`op: recall\` is not the one that pays the cold init. Fired fire-and-forget
|
|
1262
|
-
// right before \`listen()\` below, never awaited by startup: this is a best-effort head start, not
|
|
1263
|
-
// a guarantee — a request landing in the few-hundred-ms window before it completes still pays the
|
|
1264
|
-
// cold cost exactly as before this amendment, and a warm-up failure (no core module, engine
|
|
1265
|
-
// error) is silently swallowed — never-block applies to startup exactly as it does to a request.
|
|
1266
|
-
// Measured: the slowest cold resolveAgentdbEmbedder init observed in this environment was 3653 ms
|
|
1267
|
-
// (T8 log, 2026-09-14) — 10 s leaves a wide margin without risking an unbounded warm-up hang.
|
|
1268
|
-
const WARMUP_TIMEOUT_MS = 10_000;
|
|
1269
|
-
async function warmUpHybridEngine() {
|
|
1270
|
-
const core = await loadCoreModule();
|
|
1271
|
-
if (core === undefined) return;
|
|
1272
|
-
const guard = new Promise((resolve) => {
|
|
1273
|
-
const t = setTimeout(resolve, WARMUP_TIMEOUT_MS);
|
|
1274
|
-
t.unref?.();
|
|
1275
|
-
});
|
|
1276
|
-
// An empty-string query still exercises the FULL semantic leg (embed + engine.search), which is
|
|
1277
|
-
// exactly what needs warming; recallHybrid degrades any error inside it honestly, so nothing
|
|
1278
|
-
// here needs its own try/catch beyond the outer .catch(() => {}) at the call site below.
|
|
1279
|
-
await Promise.race([core.recallHybrid(PROJECT, '', { limit: 1, mode: 'hook', deferExposures: true }), guard]);
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
1432
|
let idleTimer;
|
|
1283
1433
|
let lastActivityAt = Date.now();
|
|
1284
1434
|
const touch = () => {
|
|
@@ -1353,9 +1503,6 @@ async function main() {
|
|
|
1353
1503
|
|
|
1354
1504
|
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => shutdown(0));
|
|
1355
1505
|
|
|
1356
|
-
// AM-1: fire-and-forget, never awaited — bind proceeds immediately regardless of warm-up outcome.
|
|
1357
|
-
warmUpHybridEngine().catch(() => {});
|
|
1358
|
-
|
|
1359
1506
|
// FR-3 ("absence of a receipt is not success"): \`ready\` is printed ONLY after \`listen\`'s callback
|
|
1360
1507
|
// AND a fresh \`existsSync(SOCKET)\` both confirm the socket file is actually on disk — a caller
|
|
1361
1508
|
// that greps stderr for "ready" must never see it for a socket that silently failed to bind.
|
|
@@ -1397,7 +1544,11 @@ async function main() {
|
|
|
1397
1544
|
return bindFailed(\`could not publish socket pointer: \${err?.message ?? err}\`);
|
|
1398
1545
|
}
|
|
1399
1546
|
}
|
|
1400
|
-
|
|
1547
|
+
// F1/F2 (fix round 1): own-fallback names ITS resolved dtype (\`dtype=<fp32|q8|error>\`, F1); a
|
|
1548
|
+
// core-shared daemon that failed its startup probe names that too, inline (\`(init failed: …,
|
|
1549
|
+
// will retry per request)\`, F2) — both make the honest state observable from \`ready\` alone.
|
|
1550
|
+
const embedderReadyLabel = embedderSource === 'own-fallback' ? \`\${embedderSource} dtype=\${ownFallbackDtypeLabel}\` : \`\${embedderSource}\${embedderReadyDetail}\`;
|
|
1551
|
+
log(\`ready: \${patterns.length} pattern vectors, model \${model}, socket \${SOCKET} embedder=\${embedderReadyLabel}\`);
|
|
1401
1552
|
touch();
|
|
1402
1553
|
});
|
|
1403
1554
|
server.on('error', (err) => bindFailed(err?.message ?? String(err)));
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A pure reader for Codex CLI rollout logs (feature `measurement-integrity`, ADR-001 D3).
|
|
3
|
+
*
|
|
4
|
+
* A `dz feature-adr-record --kind ledger` row for a Codex coder/reviewer stage carries
|
|
5
|
+
* `tokens: null` in 130 of 156 recorded rows (Step 0, 2026-09-16) even though the spend is sitting
|
|
6
|
+
* right there on disk: Codex writes one JSONL file per session at
|
|
7
|
+
* `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`, and nothing in the pipeline reads it. The
|
|
8
|
+
* pipeline dispatches Codex without an explicit session id (`codex exec -C <repo> -m <id> …`), so the
|
|
9
|
+
* only way to join a ledger row to the rollout that produced it is a WINDOW match: the stage's own
|
|
10
|
+
* start/end time, its `cwd`, and its model.
|
|
11
|
+
*
|
|
12
|
+
* PURE — this module never opens `~/.codex/sessions` itself; the CLI reads the files and hands their
|
|
13
|
+
* TEXT to {@link parseCodexRollout}. It must never gain a `node:fs` import (the `core-boundary`
|
|
14
|
+
* ratchet, `test/core-boundary.test.ts`, pins the current file/import count).
|
|
15
|
+
*
|
|
16
|
+
* ## A measured schema correction (read before touching the parser)
|
|
17
|
+
*
|
|
18
|
+
* Step 0's assessment described the usage record as `type: "token_count"`, keyed
|
|
19
|
+
* `payload.info.total_token_usage`. A live probe of this machine's `~/.codex/sessions` (2026-09-16,
|
|
20
|
+
* `cli_version: "0.154.0"`, every rollout from the last two days) found NO such record — the CURRENT
|
|
21
|
+
* shape is `type: "token_usage_record"`, keyed `payload.usage`, with the same five sub-fields
|
|
22
|
+
* (`input_tokens`, `cached_input_tokens`, `output_tokens`, `reasoning_output_tokens`,
|
|
23
|
+
* `total_tokens`). The model id lives on `type: "turn_context"`'s `payload.model` (not on
|
|
24
|
+
* `session_meta`, as Step 0 assumed), and `cwd` is carried by BOTH `session_meta.payload.cwd` and
|
|
25
|
+
* `turn_context.payload.cwd`. Rather than build against a shape that no longer exists on this
|
|
26
|
+
* machine, {@link parseCodexRollout} accepts BOTH the documented legacy shape and the measured
|
|
27
|
+
* current one — Codex CLI versions drift the schema (C-2: this module depends on no version beyond
|
|
28
|
+
* the fields it reads), and a reader that understands only a shape nothing on disk still emits would
|
|
29
|
+
* fail FR-5 at the exact thing it exists to fix.
|
|
30
|
+
*
|
|
31
|
+
* @packageDocumentation
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
interface RawRecord {
|
|
35
|
+
readonly type?: unknown;
|
|
36
|
+
readonly timestamp?: unknown;
|
|
37
|
+
readonly ts?: unknown;
|
|
38
|
+
readonly payload?: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
42
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function nonEmptyString(v: unknown): string | null {
|
|
46
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function finiteNonNegative(v: unknown): number {
|
|
50
|
+
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Epoch ms from a record's own `timestamp` (current schema) or `ts` (legacy/defensive), or `null`. */
|
|
54
|
+
function recordTimeMs(rec: Record<string, unknown>): number | null {
|
|
55
|
+
const raw = rec['timestamp'] ?? rec['ts'];
|
|
56
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
|
57
|
+
if (typeof raw === 'string') {
|
|
58
|
+
const ms = Date.parse(raw);
|
|
59
|
+
return Number.isFinite(ms) ? ms : null;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isoOrNull(ms: number | null): string | null {
|
|
65
|
+
if (ms === null || !Number.isFinite(ms) || Math.abs(ms) > 8.64e15) return null;
|
|
66
|
+
try {
|
|
67
|
+
return new Date(ms).toISOString();
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CodexRolloutTotals {
|
|
74
|
+
readonly input: number;
|
|
75
|
+
readonly cachedInput: number;
|
|
76
|
+
readonly output: number;
|
|
77
|
+
readonly reasoning: number;
|
|
78
|
+
readonly total: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* measurement-integrity fix-round-1/F5 (Codex r1 HIGH #5): one TURN of a session — the span between
|
|
83
|
+
* one `turn_context` record and the next (or the file's last record, for the final turn). A turn
|
|
84
|
+
* carries its OWN model/cwd (from ITS `turn_context`) and, when a usage-bearing record (`token_count`
|
|
85
|
+
* / `token_usage_record`) was seen while this turn was current, that record's totals — `null` when no
|
|
86
|
+
* such record fell inside this turn's interval (nothing to attribute to it).
|
|
87
|
+
*/
|
|
88
|
+
export interface CodexRolloutTurn {
|
|
89
|
+
readonly model: string | null;
|
|
90
|
+
readonly cwd: string | null;
|
|
91
|
+
readonly startedAt: string | null;
|
|
92
|
+
readonly endedAt: string | null;
|
|
93
|
+
readonly totals: CodexRolloutTotals | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CodexRollout {
|
|
97
|
+
readonly id: string;
|
|
98
|
+
readonly cwd: string | null;
|
|
99
|
+
readonly model: string | null;
|
|
100
|
+
/** ISO, or `null` when no record in the file carried a parseable timestamp. */
|
|
101
|
+
readonly startedAt: string | null;
|
|
102
|
+
readonly endedAt: string | null;
|
|
103
|
+
readonly totals: CodexRolloutTotals;
|
|
104
|
+
/** measurement-integrity fix-round-1/F5: `'turn'` when the file carried at least one `turn_context`
|
|
105
|
+
* record (the measured current schema always does) — {@link matchCodexRollouts} then matches at
|
|
106
|
+
* TURN granularity, never against this whole session's wide interval. `'session'` when the schema
|
|
107
|
+
* gave no turn boundaries at all (the legacy shape Step 0 documented) — matching honestly falls
|
|
108
|
+
* back to the whole-session interval, and that fact travels with the result rather than being
|
|
109
|
+
* silently assumed away. */
|
|
110
|
+
readonly granularity: 'turn' | 'session';
|
|
111
|
+
/** turns whose open or close boundary carried no timestamp — reported, never matched. */
|
|
112
|
+
readonly unmatchableTurns: number;
|
|
113
|
+
/** Empty when `granularity === 'session'`. */
|
|
114
|
+
readonly turns: readonly CodexRolloutTurn[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface CodexRolloutParseError {
|
|
118
|
+
readonly error: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Pull `{input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens, total_tokens}`
|
|
122
|
+
* (both schemas use these five field names) out of a usage-bearing sub-object. */
|
|
123
|
+
function totalsFrom(usage: Record<string, unknown>): CodexRolloutTotals {
|
|
124
|
+
return {
|
|
125
|
+
input: finiteNonNegative(usage['input_tokens']),
|
|
126
|
+
cachedInput: finiteNonNegative(usage['cached_input_tokens']),
|
|
127
|
+
output: finiteNonNegative(usage['output_tokens']),
|
|
128
|
+
reasoning: finiteNonNegative(usage['reasoning_output_tokens']),
|
|
129
|
+
total: finiteNonNegative(usage['total_tokens']),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Parse ONE rollout file's full text into a {@link CodexRollout}. Pure, never-throws; a corrupt line
|
|
135
|
+
* is skipped exactly the way `extractCostSamples` (`cost-ledger.ts`) skips one.
|
|
136
|
+
*
|
|
137
|
+
* `fileName`, when given, is used ONLY as a last-resort `id` source (the `rollout-<ts>-<uuid>.jsonl`
|
|
138
|
+
* name's own uuid) when no `session_meta` record carried one — never trusted over the file's own
|
|
139
|
+
* content.
|
|
140
|
+
*/
|
|
141
|
+
export function parseCodexRollout(text: string, fileName?: string): CodexRollout | CodexRolloutParseError {
|
|
142
|
+
if (typeof text !== 'string' || text.trim().length === 0) {
|
|
143
|
+
return { error: 'empty rollout text' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let id: string | null = null;
|
|
147
|
+
let cwd: string | null = null;
|
|
148
|
+
let sessionMetaModel: string | null = null;
|
|
149
|
+
let turnContextModel: string | null = null;
|
|
150
|
+
let firstMs: number | null = null;
|
|
151
|
+
let lastMs: number | null = null;
|
|
152
|
+
let lastTotals: CodexRolloutTotals | null = null;
|
|
153
|
+
let sawAnyRecord = false;
|
|
154
|
+
|
|
155
|
+
// measurement-integrity fix-round-1/F5 (Codex r1 HIGH #5): each `turn_context` record OPENS a new
|
|
156
|
+
// turn, in file order. `open` is the turn currently being built; `turns` collects CLOSED ones. A
|
|
157
|
+
// turn closes when the NEXT `turn_context` is seen (its `endedAt` is that boundary's own
|
|
158
|
+
// timestamp) or, for the LAST open turn, at end-of-file (`endedAt` = the last record's timestamp).
|
|
159
|
+
// A usage-bearing record is attached to whichever turn is open at its own timestamp — `null` stays
|
|
160
|
+
// on a turn that never saw one, so a caller can tell "nothing to attribute here" from "attributed
|
|
161
|
+
// zero".
|
|
162
|
+
// Lead delta after Codex r2 (#5 PARTIAL, new HIGH #1): a turn's totals are the DELTA of the
|
|
163
|
+
// session-cumulative usage between its open and close (the record's own usage counter is
|
|
164
|
+
// cumulative for the session — assigning the last cumulative total to a turn made the second turn
|
|
165
|
+
// carry the first one's tokens). `startedMs: null` marks a turn opened by a `turn_context` WITHOUT
|
|
166
|
+
// a timestamp: it still closes the previous turn (so no usage can leak into it) but can never be
|
|
167
|
+
// matched to a window — the rollout reports it under `unmatchableTurns`.
|
|
168
|
+
interface OpenTurn { model: string | null; cwd: string | null; startedMs: number | null; baseline: CodexRolloutTotals | null; totals: CodexRolloutTotals | null }
|
|
169
|
+
const closedTurns: CodexRolloutTurn[] = [];
|
|
170
|
+
let open: OpenTurn | null = null;
|
|
171
|
+
|
|
172
|
+
let unmatchableTurns = 0;
|
|
173
|
+
const closeOpenTurn = (endMs: number | null): void => {
|
|
174
|
+
if (open === null) return;
|
|
175
|
+
if (open.startedMs === null || endMs === null) unmatchableTurns += 1;
|
|
176
|
+
closedTurns.push({
|
|
177
|
+
model: open.model,
|
|
178
|
+
cwd: open.cwd,
|
|
179
|
+
startedAt: open.startedMs === null ? null : isoOrNull(open.startedMs),
|
|
180
|
+
endedAt: endMs === null ? null : isoOrNull(endMs),
|
|
181
|
+
totals: open.totals,
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
const deltaTotals = (now: CodexRolloutTotals, base: CodexRolloutTotals | null): CodexRolloutTotals => {
|
|
185
|
+
if (base === null) return now;
|
|
186
|
+
const d = (a: number, b: number): number => (a - b >= 0 ? a - b : a); // a counter that went DOWN is per-record, not cumulative
|
|
187
|
+
return { input: d(now.input, base.input), cachedInput: d(now.cachedInput, base.cachedInput), output: d(now.output, base.output), reasoning: d(now.reasoning, base.reasoning), total: d(now.total, base.total) };
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
for (const line of text.split('\n')) {
|
|
191
|
+
if (line.length === 0) continue;
|
|
192
|
+
let rec: unknown;
|
|
193
|
+
try {
|
|
194
|
+
rec = JSON.parse(line);
|
|
195
|
+
} catch {
|
|
196
|
+
continue; // corrupt line — skip, never throw
|
|
197
|
+
}
|
|
198
|
+
if (!isRecord(rec)) continue;
|
|
199
|
+
sawAnyRecord = true;
|
|
200
|
+
|
|
201
|
+
const ms = recordTimeMs(rec);
|
|
202
|
+
if (ms !== null) {
|
|
203
|
+
firstMs = firstMs === null ? ms : Math.min(firstMs, ms);
|
|
204
|
+
lastMs = lastMs === null ? ms : Math.max(lastMs, ms);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const type = rec['type'];
|
|
208
|
+
const payload = isRecord(rec['payload']) ? rec['payload'] : null;
|
|
209
|
+
if (payload === null) continue;
|
|
210
|
+
|
|
211
|
+
if (type === 'session_meta') {
|
|
212
|
+
if (id === null) id = nonEmptyString(payload['session_id']) ?? nonEmptyString(payload['id']);
|
|
213
|
+
if (cwd === null) cwd = nonEmptyString(payload['cwd']);
|
|
214
|
+
// Step 0's documented (legacy, not observed live on this machine) shape put `model` directly on
|
|
215
|
+
// `session_meta` — accepted here too, but `turnContextModel` always wins at the end (below)
|
|
216
|
+
// since that is what the measured current schema actually carries.
|
|
217
|
+
if (sessionMetaModel === null) sessionMetaModel = nonEmptyString(payload['model']);
|
|
218
|
+
} else if (type === 'turn_context') {
|
|
219
|
+
if (turnContextModel === null) turnContextModel = nonEmptyString(payload['model']);
|
|
220
|
+
if (cwd === null) cwd = nonEmptyString(payload['cwd']);
|
|
221
|
+
// Close the previous open turn AT this boundary (even when the boundary has no timestamp —
|
|
222
|
+
// the previous turn must stop absorbing usage), then open the new one.
|
|
223
|
+
closeOpenTurn(ms);
|
|
224
|
+
open = { model: nonEmptyString(payload['model']), cwd: nonEmptyString(payload['cwd']) ?? cwd, startedMs: ms, baseline: lastTotals, totals: null };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Legacy shape (Step 0's documented one, not observed live on this machine 2026-09-16):
|
|
228
|
+
// `type: "token_count"`, `payload.info.total_token_usage`.
|
|
229
|
+
if (type === 'token_count') {
|
|
230
|
+
const info = isRecord(payload['info']) ? payload['info'] : null;
|
|
231
|
+
const usage = info !== null && isRecord(info['total_token_usage']) ? info['total_token_usage'] : null;
|
|
232
|
+
if (usage !== null) {
|
|
233
|
+
const t = totalsFrom(usage);
|
|
234
|
+
if (open !== null) open.totals = deltaTotals(t, open.baseline);
|
|
235
|
+
lastTotals = t;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Current shape (measured live, cli_version 0.154.0): `type: "token_usage_record"`,
|
|
239
|
+
// `payload.usage`.
|
|
240
|
+
if (type === 'token_usage_record') {
|
|
241
|
+
const usage = isRecord(payload['usage']) ? payload['usage'] : null;
|
|
242
|
+
if (usage !== null) {
|
|
243
|
+
const t = totalsFrom(usage);
|
|
244
|
+
if (open !== null) open.totals = deltaTotals(t, open.baseline);
|
|
245
|
+
lastTotals = t;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (open !== null && lastMs !== null) closeOpenTurn(lastMs);
|
|
250
|
+
|
|
251
|
+
if (!sawAnyRecord) return { error: 'no parseable JSON lines in rollout text' };
|
|
252
|
+
if (id === null) {
|
|
253
|
+
// Last resort: the uuid embedded in `rollout-<ts>-<uuid>.jsonl` — never invented, only read back.
|
|
254
|
+
// A plain "greedy dash" regex would stop at the uuid's OWN internal dashes (its 8-4-4-4-12 hex
|
|
255
|
+
// groups), so this matches the canonical uuid shape explicitly rather than "everything after the
|
|
256
|
+
// last dash".
|
|
257
|
+
const m = typeof fileName === 'string'
|
|
258
|
+
? /([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl$/.exec(fileName)
|
|
259
|
+
: null;
|
|
260
|
+
id = m !== null ? (m[1] ?? null) : null;
|
|
261
|
+
}
|
|
262
|
+
if (id === null) return { error: 'no session_meta record and no id in fileName — cannot identify this rollout' };
|
|
263
|
+
if (lastTotals === null) {
|
|
264
|
+
return { error: 'no token_count or token_usage_record entry — nothing to attribute' };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
id,
|
|
269
|
+
cwd,
|
|
270
|
+
model: turnContextModel ?? sessionMetaModel,
|
|
271
|
+
startedAt: isoOrNull(firstMs),
|
|
272
|
+
endedAt: isoOrNull(lastMs),
|
|
273
|
+
totals: lastTotals,
|
|
274
|
+
granularity: closedTurns.length > 0 ? 'turn' : 'session',
|
|
275
|
+
unmatchableTurns,
|
|
276
|
+
turns: closedTurns,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export type CodexRolloutMatch =
|
|
281
|
+
| { readonly status: 'none' }
|
|
282
|
+
| { readonly status: 'one'; readonly rollout: CodexRollout }
|
|
283
|
+
| { readonly status: 'ambiguous'; readonly candidates: readonly CodexRollout[] };
|
|
284
|
+
|
|
285
|
+
export interface CodexRolloutMatchWindow {
|
|
286
|
+
/** ISO instant — the window's lower bound. */
|
|
287
|
+
readonly from: string;
|
|
288
|
+
/** ISO instant — the window's upper bound. */
|
|
289
|
+
readonly to: string;
|
|
290
|
+
/** Exact match against {@link CodexRollout.cwd}, when given. */
|
|
291
|
+
readonly cwd?: string;
|
|
292
|
+
/** Exact match against {@link CodexRollout.model}, when given. */
|
|
293
|
+
readonly model?: string;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* measurement-integrity fix-round-1/F5 (Codex r1 HIGH #5): every candidate window `matchCodexRollouts`
|
|
298
|
+
* may attribute spend to, at the SHARPEST granularity `parseCodexRollout` could recover from the
|
|
299
|
+
* file. For a `granularity: 'turn'` rollout this is one candidate PER TURN THAT ACTUALLY CARRIES
|
|
300
|
+
* USAGE (a turn nothing was ever attributed to yields no candidate — there is nothing honest to
|
|
301
|
+
* report for it); for `granularity: 'session'` it is exactly one candidate, the whole file, exactly
|
|
302
|
+
* as this reader behaved before this fix.
|
|
303
|
+
*
|
|
304
|
+
* This is the fix for the CRITICAL scenario the Codex review named: the OLD matcher tested the
|
|
305
|
+
* whole session's `[startedAt, endedAt]` against the query window, so ANY brief overlap with that wide
|
|
306
|
+
* interval could attribute an entire multi-turn session's cumulative spend (and, potentially, another
|
|
307
|
+
* turn's DIFFERENT model) to one stage. Scoping candidates to turns means two turns of the SAME
|
|
308
|
+
* session that only one of them overlaps the window can no longer collide — and two turns that BOTH
|
|
309
|
+
* overlap it correctly produce two candidates, which the caller below turns into `ambiguous` rather
|
|
310
|
+
* than an arbitrary pick (this is also where "the model of every usage-bearing turn matching a window
|
|
311
|
+
* must agree" ends up enforced: two turns with different models can only both match by being two
|
|
312
|
+
* SEPARATE candidates, which is ambiguous by construction — there is no path where a mismatch is
|
|
313
|
+
* silently resolved to one of them).
|
|
314
|
+
*/
|
|
315
|
+
function candidateViewsOf(r: CodexRollout): readonly CodexRollout[] {
|
|
316
|
+
if (r.granularity === 'session') return [r];
|
|
317
|
+
const out: CodexRollout[] = [];
|
|
318
|
+
for (const turn of r.turns) {
|
|
319
|
+
if (turn.totals === null) continue; // nothing was ever attributed to this turn — not a candidate
|
|
320
|
+
out.push({
|
|
321
|
+
id: r.id,
|
|
322
|
+
unmatchableTurns: r.unmatchableTurns,
|
|
323
|
+
cwd: turn.cwd,
|
|
324
|
+
model: turn.model,
|
|
325
|
+
startedAt: turn.startedAt,
|
|
326
|
+
endedAt: turn.endedAt,
|
|
327
|
+
totals: turn.totals,
|
|
328
|
+
granularity: 'turn',
|
|
329
|
+
turns: [turn],
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Which candidate VIEWS (session-level, or — per {@link candidateViewsOf} — turn-level whenever the
|
|
337
|
+
* schema recovered turn boundaries) have an interval that OVERLAPS the given `[from, to]` window
|
|
338
|
+
* (never nearest-in-time — ADR-001 D3 rejects "closest by clock" because two reviews back to back
|
|
339
|
+
* would attribute one's spend to the other). A candidate with no parseable timestamps never matches —
|
|
340
|
+
* an unattributable interval is not a wildcard.
|
|
341
|
+
*
|
|
342
|
+
* `0` matches → `{status:'none'}`. `1` → `{status:'one', rollout}`. `>1` → `{status:'ambiguous',
|
|
343
|
+
* candidates}` — NEVER an arbitrary pick of "the first" (NFR-3). `>1` also covers the case where two
|
|
344
|
+
* DIFFERENT turns (of the same or different rollouts) overlap the window with different models — that
|
|
345
|
+
* disagreement can never resolve to a lone `'one'`, it always surfaces as `'ambiguous'`.
|
|
346
|
+
*/
|
|
347
|
+
export function matchCodexRollouts(
|
|
348
|
+
rollouts: readonly CodexRollout[],
|
|
349
|
+
window: CodexRolloutMatchWindow,
|
|
350
|
+
): CodexRolloutMatch {
|
|
351
|
+
const fromMs = Date.parse(window.from);
|
|
352
|
+
const toMs = Date.parse(window.to);
|
|
353
|
+
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs > toMs) return { status: 'none' };
|
|
354
|
+
|
|
355
|
+
const candidates: CodexRollout[] = [];
|
|
356
|
+
for (const r of rollouts) {
|
|
357
|
+
for (const view of candidateViewsOf(r)) {
|
|
358
|
+
if (view.startedAt === null || view.endedAt === null) continue;
|
|
359
|
+
const startMs = Date.parse(view.startedAt);
|
|
360
|
+
const endMs = Date.parse(view.endedAt);
|
|
361
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue;
|
|
362
|
+
// Lead delta after Codex r2 (#5): the turn must START inside the window — a turn that merely
|
|
363
|
+
// brushes the window's edge (any-overlap) is exactly how a neighbouring dispatch's turn leaks in.
|
|
364
|
+
if (startMs < fromMs || startMs > toMs) continue;
|
|
365
|
+
if (window.cwd !== undefined && view.cwd !== window.cwd) continue;
|
|
366
|
+
if (window.model !== undefined && view.model !== window.model) continue;
|
|
367
|
+
candidates.push(view);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (candidates.length === 0) return { status: 'none' };
|
|
372
|
+
if (candidates.length === 1) return { status: 'one', rollout: candidates[0]! };
|
|
373
|
+
return { status: 'ambiguous', candidates };
|
|
374
|
+
}
|