@dzhechkov/harness-core 0.8.35 → 0.8.37
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 +224 -104
- package/README.md +335 -10
- 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 +57 -1
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +450 -52
- package/dist/apply-leg.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +67 -5
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +13 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +13 -1
- package/dist/codex-hooks.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 +345 -0
- package/dist/cross-family-control.d.ts.map +1 -0
- package/dist/cross-family-control.js +802 -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 +122 -2
- 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 +23 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -7
- 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/operations.d.ts +1 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +18 -2
- package/dist/operations.js.map +1 -1
- package/dist/publish.d.ts +59 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +205 -32
- package/dist/publish.js.map +1 -1
- 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/release-line.d.ts +16 -0
- package/dist/release-line.d.ts.map +1 -1
- package/dist/release-line.js +31 -0
- package/dist/release-line.js.map +1 -1
- package/dist/round.d.ts +74 -1
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +112 -4
- package/dist/round.js.map +1 -1
- package/dist/run-records.d.ts +60 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +244 -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/dist/vector-tier.d.ts +34 -3
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +105 -14
- package/dist/vector-tier.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +403 -103
- package/src/agentdb-index.ts +423 -60
- package/src/apply-leg.ts +469 -50
- package/src/codex-hooks-assets.ts +67 -5
- package/src/codex-hooks.ts +13 -1
- package/src/codex-rollouts.ts +374 -0
- package/src/cost-ledger.ts +232 -24
- package/src/cross-family-control.ts +960 -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 +139 -2
- package/src/feature-adr-stage-canon.ts +141 -0
- package/src/index.ts +66 -7
- package/src/loop-blobs.generated.ts +4 -4
- package/src/mutation-gate.ts +316 -0
- package/src/operations.ts +18 -3
- package/src/publish.ts +247 -30
- package/src/qe-bridge.ts +4 -2
- package/src/qe-findings.ts +463 -0
- package/src/recap.ts +10 -3
- package/src/release-line.ts +32 -0
- package/src/round.ts +165 -6
- package/src/run-records.ts +282 -2
- package/src/score.ts +115 -6
- package/src/vector-tier.ts +127 -14
package/src/apply-leg.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { connect as netConnect } from 'node:net';
|
|
|
33
33
|
// (core-boundary.test.ts's ratchet only counts fs/child_process/https). `probeApplyLeg`'s temp probe
|
|
34
34
|
// cwd reuses the existing top-level 'node:fs' import above (mkdtempSync/rmSync added to that SAME
|
|
35
35
|
// import statement, not a new one) so the ratchet stays at its pinned files:63 imports:69.
|
|
36
|
-
import { tmpdir } from 'node:os';
|
|
36
|
+
import { tmpdir, uptime } from 'node:os';
|
|
37
37
|
import { join, resolve } from 'node:path';
|
|
38
38
|
import { hookCommandsOf } from './managed-hooks.js';
|
|
39
39
|
import { patternRecordId, recordPattern, removePatternsByIds, loadStorePatternsSync, type PatternRecord, type RemovePatternsResult } from './patterns.js';
|
|
@@ -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)));
|
|
@@ -1768,6 +1919,19 @@ export interface ApplyLegProbeResult {
|
|
|
1768
1919
|
* reason=…` stderr line when present, else a best-effort description of what went wrong. */
|
|
1769
1920
|
readonly reason?: string;
|
|
1770
1921
|
readonly elapsedMs: number;
|
|
1922
|
+
/** Fix round 1 (HIGH-1): the KILL-ATTEMPT fact, straight from `probeHookLiveness`'s own
|
|
1923
|
+
* `groupKillAttempted` — present only on the branches that actually reached a spawn (absent for the
|
|
1924
|
+
* early "not installed" / "no hook configured" / "legacy command" / "beacon write failed"
|
|
1925
|
+
* returns, none of which ever call `probeHookLiveness`). This is deliberately a DIFFERENT fact
|
|
1926
|
+
* from "is the grandchild still alive" — a caller observes both separately, never conflates them. */
|
|
1927
|
+
readonly groupKillAttempted?: boolean;
|
|
1928
|
+
/** FR-3 (feature `apply-leg-daemon-hygiene`): present exactly when the pre-probe scavenge of
|
|
1929
|
+
* STALE beacon-domain records failed — the reason comes straight from {@link
|
|
1930
|
+
* scavengeStaleProbeBeacons}'s own `error`, never swallowed. Independent of `ok`/`reason`: a
|
|
1931
|
+
* scavenge failure does not itself flip `ok` to `false` (the probe's own injection result may
|
|
1932
|
+
* still be perfectly genuine), but it IS a fact `dz doctor`/`dz parity` callers can surface
|
|
1933
|
+
* rather than lose. */
|
|
1934
|
+
readonly scavengeError?: string;
|
|
1771
1935
|
}
|
|
1772
1936
|
|
|
1773
1937
|
/** Words a real prompt needs to clear `hasEnoughSignal` (recall-hook-policy.ts: `MIN_PROMPT_CHARS`
|
|
@@ -1778,6 +1942,221 @@ const PROBE_PROMPT_WORDS = 'apply leg live probe';
|
|
|
1778
1942
|
* findable and excludable — never `dz-teach`/`general`, which would blend it into real lessons. */
|
|
1779
1943
|
const PROBE_BEACON_DOMAIN = 'apply-leg-probe';
|
|
1780
1944
|
|
|
1945
|
+
/**
|
|
1946
|
+
* FR-3 (feature `apply-leg-daemon-hygiene`): the scavenger that ran at the top of every probe used
|
|
1947
|
+
* to delete EVERY beacon-domain record unconditionally — safe against a probe killed mid-flight
|
|
1948
|
+
* (Codex round-2's own reason for the scavenger existing at all), but WRONG the moment two probes
|
|
1949
|
+
* from two DIFFERENT sessions can be live against the SAME store at once: the second probe's
|
|
1950
|
+
* scavenge deletes the first probe's still-in-flight beacon, and the first probe then reports a
|
|
1951
|
+
* false `ok:false` (its own hook query returns nothing, because the lesson it was about to match
|
|
1952
|
+
* against is already gone) — a false-red `dz doctor`/`dz parity` parity check with no defect behind
|
|
1953
|
+
* it. The fix: tag every beacon with its OWNER (`probe-owner=<pid>:<startedMs>`, embedded in the
|
|
1954
|
+
* pattern TEXT so it survives a round-trip through any store tier without a schema change — NFR-1
|
|
1955
|
+
* forbids a new column) and scavenge ONLY a beacon whose owner is provably gone: the pid no longer
|
|
1956
|
+
* answers `process.kill(pid, 0)`, OR the beacon has outlived its TTL (see {@link
|
|
1957
|
+
* scavengeStaleProbeBeacons}'s own `ttlMs` parameter).
|
|
1958
|
+
*/
|
|
1959
|
+
const PROBE_OWNER_TAG_PREFIX = 'probe-owner=';
|
|
1960
|
+
/** Fix round 1 (HIGH-6 residual): a second, independent tag alongside `probe-owner=` — the WRITING
|
|
1961
|
+
* process's own OS-level start time (epoch ms, from `/proc/<pid>/stat`), so the scavenger can tell a
|
|
1962
|
+
* PID-REUSE case (the recorded pid is technically "alive" per `process.kill(pid,0)`, but the LIVE
|
|
1963
|
+
* process at that pid started at a different time than the one that wrote the beacon) apart from
|
|
1964
|
+
* the genuine same-process case. Absent whenever the write-time `/proc` read fails (non-Linux,
|
|
1965
|
+
* permission) — the scavenger then falls back to the plain alive+TTL check alone, unchanged. */
|
|
1966
|
+
const PROC_START_TAG_PREFIX = 'proc-start=';
|
|
1967
|
+
/** Lead delta after Codex round 2 (HIGH-4): the beacon's OWN expiry, in epoch ms, written by the
|
|
1968
|
+
* probe that owns it. The pre-delta scavenger applied ITS OWN `ttlMs` to SOMEONE ELSE'S beacon, so a
|
|
1969
|
+
* default-budget probe (ttl 60 s) deleted the live beacon of a widened-budget probe (ttl 360 s) after
|
|
1970
|
+
* 60 s — the very false-red this hardening exists to prevent, one level up. A beacon now states when
|
|
1971
|
+
* IT expires; the scavenger's own `ttlMs` is only the fallback for a beacon written before this tag
|
|
1972
|
+
* existed. */
|
|
1973
|
+
const PROBE_EXPIRES_TAG_PREFIX = 'probe-expires=';
|
|
1974
|
+
|
|
1975
|
+
/**
|
|
1976
|
+
* Fix round 1 (HIGH-6): the pre-fix-round TTL was a flat 60 000 ms, independent of the probe's own
|
|
1977
|
+
* `timeoutMs` — a caller that legitimately widens `timeoutMs` past that (a widened, suspended, or
|
|
1978
|
+
* heavily loaded probe) could have its OWN still-in-flight beacon scavenged by a concurrent probe
|
|
1979
|
+
* before it ever replies. The TTL is now DERIVED from the probe's own `timeoutMs`
|
|
1980
|
+
* (`max(timeoutMs * 3, 60_000)`, see {@link probeApplyLeg}'s call site) so a widened timeout widens
|
|
1981
|
+
* its own protection window too; the 60 000 ms floor keeps the pre-fix-round generous margin for the
|
|
1982
|
+
* default (unwidened) case. Exported as a named constant only for the floor value — the ACTUAL TTL
|
|
1983
|
+
* used by a given probe is always `ttlMs`, computed at the call site, never this constant alone.
|
|
1984
|
+
*/
|
|
1985
|
+
const PROBE_BEACON_TTL_FLOOR_MS = 60_000;
|
|
1986
|
+
|
|
1987
|
+
function formatProbeOwner(pid: number, startedMs: number): string {
|
|
1988
|
+
return `${PROBE_OWNER_TAG_PREFIX}${pid}:${startedMs}`;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
/** Parses the `probe-owner=<pid>:<startedMs>` tag out of a beacon's pattern text. `undefined` for
|
|
1992
|
+
* any beacon predating this tag (an older deployed core wrote it) — treated by the scavenger as
|
|
1993
|
+
* ownerless and therefore always safe to remove (the pre-FR-3 behavior for exactly that case). */
|
|
1994
|
+
function parseProbeOwner(patternText: string): { readonly pid: number; readonly startedMs: number } | undefined {
|
|
1995
|
+
const idx = patternText.indexOf(PROBE_OWNER_TAG_PREFIX);
|
|
1996
|
+
if (idx === -1) return undefined;
|
|
1997
|
+
const match = /probe-owner=(\d+):(\d+)/u.exec(patternText.slice(idx));
|
|
1998
|
+
if (match?.[1] === undefined || match[2] === undefined) return undefined;
|
|
1999
|
+
return { pid: Number(match[1]), startedMs: Number(match[2]) };
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
function formatProcStart(procStartedAtMs: number): string {
|
|
2003
|
+
return ` ${PROC_START_TAG_PREFIX}${procStartedAtMs}`;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
function formatProbeExpires(expiresAtMs: number): string {
|
|
2007
|
+
return ` ${PROBE_EXPIRES_TAG_PREFIX}${expiresAtMs}`;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
/** Parses the `probe-expires=<epochMs>` tag — `undefined` for a beacon written before the tag
|
|
2011
|
+
* existed, which is exactly when the scavenger falls back to its own `ttlMs`. */
|
|
2012
|
+
function parseProbeExpires(patternText: string): number | undefined {
|
|
2013
|
+
const idx = patternText.indexOf(PROBE_EXPIRES_TAG_PREFIX);
|
|
2014
|
+
if (idx === -1) return undefined;
|
|
2015
|
+
const match = /probe-expires=(\d+)/u.exec(patternText.slice(idx));
|
|
2016
|
+
if (match?.[1] === undefined) return undefined;
|
|
2017
|
+
return Number(match[1]);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/** Parses the `proc-start=<ticks>` tag — `undefined` when absent (pre-fix-round beacon, or the
|
|
2021
|
+
* write-time `/proc` read failed). */
|
|
2022
|
+
function parseProcStart(patternText: string): number | undefined {
|
|
2023
|
+
const idx = patternText.indexOf(PROC_START_TAG_PREFIX);
|
|
2024
|
+
if (idx === -1) return undefined;
|
|
2025
|
+
const match = /proc-start=(\d+)/u.exec(patternText.slice(idx));
|
|
2026
|
+
if (match?.[1] === undefined) return undefined;
|
|
2027
|
+
return Number(match[1]);
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
/** True when `pid` answers a liveness signal — `process.kill(pid, 0)` sends no actual signal, it
|
|
2031
|
+
* only probes whether the OS still has a process at that pid (ESRCH ⇒ dead). */
|
|
2032
|
+
function isPidAlive(pid: number): boolean {
|
|
2033
|
+
try {
|
|
2034
|
+
process.kill(pid, 0);
|
|
2035
|
+
return true;
|
|
2036
|
+
} catch (err) {
|
|
2037
|
+
return (err as NodeJS.ErrnoException).code !== 'ESRCH';
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
/**
|
|
2042
|
+
* Fix round 1 (HIGH-6 residual, Linux-only, best-effort), tightened by the lead after Codex round 2
|
|
2043
|
+
* (MEDIUM-6): a process's OWN `starttime` from `/proc/<pid>/stat` — field 22 overall, found by
|
|
2044
|
+
* skipping past the LAST `)` so a `comm` containing spaces or parens never misaligns the split —
|
|
2045
|
+
* returned as RAW TICKS SINCE BOOT, the unit the kernel reports. No wall clock is consulted and no
|
|
2046
|
+
* CLK_TCK conversion is performed, so a stepped wall clock can no longer make the same live process
|
|
2047
|
+
* look like a different one. `undefined` on ANY read/parse failure (non-Linux, permission, the
|
|
2048
|
+
* process exiting mid-read) — the caller MUST treat that as "cannot prove", never as a pass in
|
|
2049
|
+
* either direction.
|
|
2050
|
+
*/
|
|
2051
|
+
function pidStartedAtMsFromProcStat(pid: number): number | undefined {
|
|
2052
|
+
try {
|
|
2053
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8');
|
|
2054
|
+
const afterComm = stat.slice(stat.lastIndexOf(')') + 1).trim();
|
|
2055
|
+
const fields = afterComm.split(/\s+/u);
|
|
2056
|
+
// Overall field 22 (`starttime`) = fields[22 - 3] here, since fields[] starts at overall field 3
|
|
2057
|
+
// (state) once `pid (comm)` (fields 1-2) has been stripped above.
|
|
2058
|
+
const starttimeTicks = Number(fields[19]);
|
|
2059
|
+
if (!Number.isFinite(starttimeTicks)) return undefined;
|
|
2060
|
+
// Lead delta after Codex round 2 (MEDIUM-6): return the RAW ticks-since-boot, never an epoch
|
|
2061
|
+
// derived from `Date.now() - uptime()`. The derived epoch moves when the WALL CLOCK is stepped,
|
|
2062
|
+
// so the same still-running process appeared to have "started at a different time" and its LIVE
|
|
2063
|
+
// beacon was deleted as a pid-reuse. Ticks since boot are monotonic within a boot and identical
|
|
2064
|
+
// for the same process on every read; across a reboot the recorded pid is dead anyway, which the
|
|
2065
|
+
// liveness check catches first — WITHDRAWN by the lead after Codex round 4: that sentence was
|
|
2066
|
+
// wrong, because across a reboot the pid may ALREADY have been reused and can collide on start
|
|
2067
|
+
// ticks too; the honest scope is the NAMED LIMIT stated below. This also removes the CLK_TCK assumption entirely — no conversion
|
|
2068
|
+
// to milliseconds happens at all, the two values are compared in their own unit.
|
|
2069
|
+
// NAMED LIMIT (lead delta after Codex round 3, MEDIUM): identity holds WITHIN one boot. Across a
|
|
2070
|
+
// reboot both pid allocation and ticks-since-boot restart, so a beacon that somehow persisted
|
|
2071
|
+
// could in principle collide with a new process at the same pid and the same tick. A boot id
|
|
2072
|
+
// would close it; the honest scope today is "within one boot", and a reboot also means the
|
|
2073
|
+
// beacon's own TTL has almost certainly passed, which the TTL branch catches first.
|
|
2074
|
+
return starttimeTicks;
|
|
2075
|
+
} catch {
|
|
2076
|
+
return undefined;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
/** Fix round 1 (HIGH-6 residual): `process.kill(pid,0)` proves SOME process occupies `pid`, never
|
|
2081
|
+
* that it is the SAME process that wrote the beacon — PID reuse defeats the plain alive check named
|
|
2082
|
+
* as a residual limit in the review. Compares the LIVE process's own start time (from `/proc/<pid>/
|
|
2083
|
+
* stat`) against `recordedStartedMs` (the beacon's own `proc-start=` tag). A mismatch beyond {@link
|
|
2084
|
+
* PID_START_TOLERANCE_MS} means the pid was reused by an unrelated process — the true owner is
|
|
2085
|
+
* confirmed gone. `undefined` (stat unreadable) means "cannot prove either way" — the lead's decision
|
|
2086
|
+
* (fix round 1, item 6) is explicit: that MUST read as "do not remove" wherever it is consumed, never
|
|
2087
|
+
* as a pass. */
|
|
2088
|
+
function isSameProcessInstance(pid: number, recordedStartTicks: number): boolean | undefined {
|
|
2089
|
+
const actualStartTicks = pidStartedAtMsFromProcStat(pid);
|
|
2090
|
+
if (actualStartTicks === undefined) return undefined;
|
|
2091
|
+
// Lead delta after Codex round 2 (MEDIUM-6): EXACT equality on ticks-since-boot. The old
|
|
2092
|
+
// ±5 000 ms tolerance existed only to absorb the CLK_TCK guess in the epoch conversion; with the
|
|
2093
|
+
// raw kernel value there is nothing to absorb, and a tolerance would re-admit the very pid-reuse
|
|
2094
|
+
// case it was meant to exclude (a reused pid started within the tolerance read as "same process").
|
|
2095
|
+
return actualStartTicks === recordedStartTicks;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
/** {@link scavengeStaleProbeBeacons}'s result — FR-3: a scavenge error is a FACT the caller can
|
|
2099
|
+
* surface, never a swallowed exception (the pre-fix `catch { /* best-effort *\/ }` this replaces). */
|
|
2100
|
+
export type ScavengeResult = { readonly ok: true; readonly removed: number } | { readonly ok: false; readonly error: string };
|
|
2101
|
+
|
|
2102
|
+
/**
|
|
2103
|
+
* FR-3/FR-4: remove only the STALE beacon-domain records in `root`'s store — owner dead
|
|
2104
|
+
* (`process.kill(pid, 0)` ⇒ ESRCH), older than `ttlMs`, OR (fix round 1, HIGH-6 residual) alive AND
|
|
2105
|
+
* within `ttlMs` but the live pid's OWN `/proc/<pid>/stat` start time no longer matches the beacon's
|
|
2106
|
+
* `proc-start=` tag — a confirmed PID-reuse case, where the recorded owner is provably gone even
|
|
2107
|
+
* though `pid` itself answers. An UNREADABLE stat at scavenge time never counts as reuse evidence —
|
|
2108
|
+
* it falls back to the plain alive+TTL verdict, per the lead's "cannot prove ⇒ do not remove"
|
|
2109
|
+
* decision. A beacon whose owner is alive, within TTL, and (when provable) confirmed the SAME
|
|
2110
|
+
* process is left untouched, even though it belongs to a different probe. An ownerless (pre-FR-3)
|
|
2111
|
+
* beacon is always treated as stale. `ttlMs` defaults to {@link PROBE_BEACON_TTL_FLOOR_MS} for a
|
|
2112
|
+
* caller that does not derive one (`apply-leg-beacon-owner.test.ts`'s own fixtures); `probeApplyLeg`
|
|
2113
|
+
* always passes its own derived value. Exported so `apply-leg-beacon-owner.test.ts` can exercise the
|
|
2114
|
+
* property directly, without needing a live hook/daemon (FR-4's red-first case needs only a store
|
|
2115
|
+
* and an injectable remover, never a real probe round-trip).
|
|
2116
|
+
*/
|
|
2117
|
+
export function scavengeStaleProbeBeacons(
|
|
2118
|
+
root: string,
|
|
2119
|
+
removeBeacon: (root: string, ids: ReadonlySet<string>) => RemovePatternsResult,
|
|
2120
|
+
ttlMs: number = PROBE_BEACON_TTL_FLOOR_MS,
|
|
2121
|
+
): ScavengeResult {
|
|
2122
|
+
try {
|
|
2123
|
+
const beacons = loadStorePatternsSync(root).filter((p) => p.domain === PROBE_BEACON_DOMAIN);
|
|
2124
|
+
const staleIds: string[] = [];
|
|
2125
|
+
for (const beacon of beacons) {
|
|
2126
|
+
const owner = parseProbeOwner(beacon.pattern);
|
|
2127
|
+
if (owner === undefined) {
|
|
2128
|
+
staleIds.push(beacon.dzId ?? patternRecordId(beacon));
|
|
2129
|
+
continue;
|
|
2130
|
+
}
|
|
2131
|
+
const alive = isPidAlive(owner.pid);
|
|
2132
|
+
// Lead delta after Codex round 2 (HIGH-4): the beacon's OWN expiry wins over this scavenger's
|
|
2133
|
+
// `ttlMs`, which belongs to a DIFFERENT probe and knows nothing of this owner's budget.
|
|
2134
|
+
const ownExpiresAtMs = parseProbeExpires(beacon.pattern);
|
|
2135
|
+
const withinTtl = ownExpiresAtMs !== undefined
|
|
2136
|
+
? Date.now() <= ownExpiresAtMs
|
|
2137
|
+
: Date.now() - owner.startedMs <= ttlMs;
|
|
2138
|
+
if (!alive || !withinTtl) {
|
|
2139
|
+
staleIds.push(beacon.dzId ?? patternRecordId(beacon));
|
|
2140
|
+
continue;
|
|
2141
|
+
}
|
|
2142
|
+
// alive AND within TTL — the plain check says "keep", but confirm it is the SAME process, not
|
|
2143
|
+
// a reused pid, whenever the beacon carries the (best-effort) proc-start tag.
|
|
2144
|
+
const procStart = parseProcStart(beacon.pattern);
|
|
2145
|
+
if (procStart !== undefined) {
|
|
2146
|
+
const same = isSameProcessInstance(owner.pid, procStart);
|
|
2147
|
+
if (same === false) staleIds.push(beacon.dzId ?? patternRecordId(beacon)); // confirmed reuse
|
|
2148
|
+
// same === true, or same === undefined (unreadable ⇒ cannot prove ⇒ do not remove): keep.
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
if (staleIds.length === 0) return { ok: true, removed: 0 };
|
|
2152
|
+
const result = removeBeacon(root, new Set(staleIds));
|
|
2153
|
+
if (result.error !== undefined) return { ok: false, error: result.error };
|
|
2154
|
+
return { ok: true, removed: result.removed };
|
|
2155
|
+
} catch (err) {
|
|
2156
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
|
|
1781
2160
|
/**
|
|
1782
2161
|
* Live, end-to-end proof that the apply leg actually injects — ADR-001 Decision 1. `applyLegStatus`
|
|
1783
2162
|
* only proves FILES exist and are STRUCTURALLY wired (issue #2's whole defect: four green checks,
|
|
@@ -1821,6 +2200,12 @@ export async function probeApplyLeg(
|
|
|
1821
2200
|
const started = Date.now();
|
|
1822
2201
|
const elapsed = (): number => Date.now() - started;
|
|
1823
2202
|
|
|
2203
|
+
// Fix round 1 (HIGH-6): computed HERE, before the scavenger runs, so the scavenge TTL can be
|
|
2204
|
+
// derived from THIS probe's own budget rather than a flat constant blind to a caller-widened
|
|
2205
|
+
// timeout — see the `ttlMs` computation below, right before the beacon that carries its implicit
|
|
2206
|
+
// promise is written.
|
|
2207
|
+
const timeoutMs = opts.timeoutMs ?? 8000;
|
|
2208
|
+
|
|
1824
2209
|
const status = applyLegStatus(root);
|
|
1825
2210
|
if (!status.installed) {
|
|
1826
2211
|
return { ok: false, reason: 'apply-leg not installed', elapsedMs: elapsed() };
|
|
@@ -1844,8 +2229,32 @@ export async function probeApplyLeg(
|
|
|
1844
2229
|
// pattern's own text can produce it. `ok: true` therefore requires the SECRET, never the query.
|
|
1845
2230
|
const queryToken = `dzapplylegquery${process.pid}${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
|
|
1846
2231
|
const secretToken = `dzapplylegsecret${process.pid}${Date.now()}${Math.random().toString(36).slice(2, 10)}`;
|
|
2232
|
+
// FR-3: the owner tag rides the SAME pattern text as the secret — a beacon's owner is knowable
|
|
2233
|
+
// from its store record alone, no side channel, no schema change (NFR-1).
|
|
2234
|
+
const ownerTag = formatProbeOwner(process.pid, started);
|
|
2235
|
+
// Fix round 1 (HIGH-6): TTL derives from THIS probe's own timeoutMs — a caller that widens
|
|
2236
|
+
// timeoutMs (this repo's own live tests widen it to 15_000 ms) widens its own protection window
|
|
2237
|
+
// too, rather than staying pinned to a flat constant blind to that widening. Floored at the
|
|
2238
|
+
// pre-fix-round 60_000 ms so the default (unwidened) case keeps its original generous margin.
|
|
2239
|
+
// Asserted, not merely trusted — an unreasoned future edit to the formula must fail loudly right
|
|
2240
|
+
// here, where the beacon carrying this TTL's implicit promise is about to be written, rather than
|
|
2241
|
+
// silently reintroducing the false-red-under-concurrency defect this closes.
|
|
2242
|
+
const ttlMs = Math.max(timeoutMs * 3, PROBE_BEACON_TTL_FLOOR_MS);
|
|
2243
|
+
if (!(timeoutMs < ttlMs)) {
|
|
2244
|
+
throw new Error(`invariant violated: timeoutMs (${String(timeoutMs)}) must be < ttlMs (${String(ttlMs)}) — scavenger TTL formula regressed`);
|
|
2245
|
+
}
|
|
2246
|
+
// Fix round 1 (HIGH-6 residual, best-effort): the writing process's OWN OS-level start time,
|
|
2247
|
+
// captured NOW so a later scavenger can tell a genuinely-still-alive owner apart from a DIFFERENT
|
|
2248
|
+
// process that merely reused this pid (isSameProcessInstance's own doc comment). Omitted entirely
|
|
2249
|
+
// when unreadable (non-Linux, permission) — the scavenger then falls back to the plain alive+TTL
|
|
2250
|
+
// check for this beacon, unchanged from before this residual hardening.
|
|
2251
|
+
const ownProcStart = pidStartedAtMsFromProcStat(process.pid);
|
|
2252
|
+
const procStartTag = ownProcStart !== undefined ? formatProcStart(ownProcStart) : '';
|
|
2253
|
+
// Lead delta after Codex round 2 (HIGH-4): this beacon states its OWN expiry, so a concurrent
|
|
2254
|
+
// probe with a different (shorter) budget can never out-vote this probe's protection window.
|
|
2255
|
+
const expiresTag = formatProbeExpires(started + ttlMs);
|
|
1847
2256
|
const beaconPattern: PatternRecord = {
|
|
1848
|
-
pattern: `${PROBE_PROMPT_WORDS} ${queryToken} — dz doctor / dz parity live-probe marker, safe to remove. probe-secret=${secretToken}`,
|
|
2257
|
+
pattern: `${PROBE_PROMPT_WORDS} ${queryToken} — dz doctor / dz parity live-probe marker, safe to remove. probe-secret=${secretToken} ${ownerTag}${procStartTag}${expiresTag}`,
|
|
1849
2258
|
type: 'lesson-learned',
|
|
1850
2259
|
reward: 0,
|
|
1851
2260
|
domain: PROBE_BEACON_DOMAIN,
|
|
@@ -1874,12 +2283,13 @@ export async function probeApplyLeg(
|
|
|
1874
2283
|
// Codex round-2: a process killed mid-probe bypasses `finally`, so a beacon can outlive its probe.
|
|
1875
2284
|
// Every probe therefore starts by SCAVENGING any beacon left behind by an earlier one (the probe
|
|
1876
2285
|
// domain is reserved for beacons, never for user lessons) — the store is clean before AND after.
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
2286
|
+
// FR-3/FR-4: scavenging is no longer unconditional — a LIVE beacon from a DIFFERENT concurrent
|
|
2287
|
+
// probe (another session's `dz doctor`/`dz parity` against the same store) must survive; only a
|
|
2288
|
+
// beacon whose owner is dead or past its TTL is removed. A scavenge failure is a named FACT
|
|
2289
|
+
// (`scavengeError`), never a swallowed exception (the pre-fix `catch {}` this replaces).
|
|
2290
|
+
let scavengeError: string | undefined;
|
|
2291
|
+
const scavengeResult = scavengeStaleProbeBeacons(root, removeBeacon, ttlMs);
|
|
2292
|
+
if (!scavengeResult.ok) scavengeError = scavengeResult.error;
|
|
1883
2293
|
|
|
1884
2294
|
try {
|
|
1885
2295
|
wrote = true;
|
|
@@ -1903,7 +2313,6 @@ export async function probeApplyLeg(
|
|
|
1903
2313
|
mkdirSync(join(tempCwd, '.claude'), { recursive: true });
|
|
1904
2314
|
|
|
1905
2315
|
const { probeHookLiveness } = await import('./operations.js');
|
|
1906
|
-
const timeoutMs = opts.timeoutMs ?? 8000;
|
|
1907
2316
|
const probeResult = probeHookLiveness(command, JSON.stringify({ prompt: probePrompt }), {
|
|
1908
2317
|
cwd: tempCwd,
|
|
1909
2318
|
env: { ...(opts.env ?? {}), CLAUDE_PROJECT_DIR: tempCwd },
|
|
@@ -1924,12 +2333,12 @@ export async function probeApplyLeg(
|
|
|
1924
2333
|
}
|
|
1925
2334
|
|
|
1926
2335
|
if (typeof additionalContext === 'string' && additionalContext.includes(secretToken)) {
|
|
1927
|
-
result = { ok: true, elapsedMs: elapsed() };
|
|
2336
|
+
result = { ok: true, elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
|
|
1928
2337
|
} else if (typeof additionalContext === 'string' && additionalContext.includes(queryToken)) {
|
|
1929
2338
|
// AM-1: the QUERY came back but the SECRET did not — the hook (or a stub standing in for
|
|
1930
2339
|
// it) echoed its own input instead of genuinely querying the store. Named distinctly from
|
|
1931
2340
|
// every other red reason so a dead leg and a FAKING one never read the same.
|
|
1932
|
-
result = { ok: false, reason: 'echo-not-injection', elapsedMs: elapsed() };
|
|
2341
|
+
result = { ok: false, reason: 'echo-not-injection', elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
|
|
1933
2342
|
} else {
|
|
1934
2343
|
// FR-1's own reason line is the authoritative source — the hook names itself why it stayed
|
|
1935
2344
|
// quiet. Falling back to a raw stderr/status summary keeps the probe honest even against an
|
|
@@ -1937,15 +2346,16 @@ export async function probeApplyLeg(
|
|
|
1937
2346
|
const skipMatch = /\[dz-recall\] skipped reason=(\S+)/.exec(probeResult.stderr);
|
|
1938
2347
|
const skipReason = skipMatch?.[1];
|
|
1939
2348
|
if (skipReason !== undefined) {
|
|
1940
|
-
result = { ok: false, reason: skipReason, elapsedMs: elapsed() };
|
|
2349
|
+
result = { ok: false, reason: skipReason, elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
|
|
1941
2350
|
} else if (probeResult.status === null) {
|
|
1942
|
-
result = { ok: false, reason: `probe did not complete (timeout or spawn error after ${timeoutMs} ms)`, elapsedMs: elapsed() };
|
|
2351
|
+
result = { ok: false, reason: `probe did not complete (timeout or spawn error after ${timeoutMs} ms)`, elapsedMs: elapsed(), groupKillAttempted: probeResult.groupKillAttempted };
|
|
1943
2352
|
} else {
|
|
1944
2353
|
const stderrFirstLine = probeResult.stderr.trim().split('\n')[0];
|
|
1945
2354
|
result = {
|
|
1946
2355
|
ok: false,
|
|
1947
2356
|
reason: stderrFirstLine && stderrFirstLine !== '' ? stderrFirstLine : 'no beacon in additionalContext (empty or non-matching reply)',
|
|
1948
2357
|
elapsedMs: elapsed(),
|
|
2358
|
+
groupKillAttempted: probeResult.groupKillAttempted,
|
|
1949
2359
|
};
|
|
1950
2360
|
}
|
|
1951
2361
|
}
|
|
@@ -1978,11 +2388,20 @@ export async function probeApplyLeg(
|
|
|
1978
2388
|
}
|
|
1979
2389
|
|
|
1980
2390
|
if (cleanupFailed) {
|
|
2391
|
+
// a cleanup failure can only happen after `result` was assigned (the finally block runs after
|
|
2392
|
+
// the try body) — carry the kill-attempt fact through rather than dropping it on this path.
|
|
2393
|
+
// `exactOptionalPropertyTypes` forbids assigning `undefined` to an optional field explicitly, so
|
|
2394
|
+
// the key is included only when `result.groupKillAttempted` actually has a value.
|
|
1981
2395
|
return {
|
|
1982
2396
|
ok: false,
|
|
1983
2397
|
reason: `beacon-cleanup-failed: beacon ${beaconId} could not be removed (${cleanupErrMsg})`,
|
|
1984
2398
|
elapsedMs: elapsed(),
|
|
2399
|
+
...(result.groupKillAttempted !== undefined ? { groupKillAttempted: result.groupKillAttempted } : {}),
|
|
2400
|
+
...(scavengeError !== undefined ? { scavengeError } : {}),
|
|
1985
2401
|
};
|
|
1986
2402
|
}
|
|
1987
|
-
|
|
2403
|
+
// FR-3: a scavenge failure is surfaced on the SUCCESS path too — it does not override `ok`/
|
|
2404
|
+
// `reason` (this probe's own injection result may be perfectly genuine), but it is a fact a
|
|
2405
|
+
// caller should not lose.
|
|
2406
|
+
return scavengeError !== undefined ? { ...result, scavengeError } : result;
|
|
1988
2407
|
}
|