@compr/opscontext-mcp 2.11.0 → 2.12.0
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/CHANGELOG.md +33 -0
- package/dist/audit.d.ts +10 -0
- package/dist/audit.js +74 -17
- package/dist/cli.js +13 -0
- package/dist/firewall.js +17 -7
- package/dist/index.js +44 -1
- package/dist/learnings.d.ts +3 -0
- package/dist/learnings.js +100 -6
- package/dist/server-registry.d.ts +3 -0
- package/dist/server-registry.js +67 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ All notable changes to OpsContext for AI Agents (previously ContextEngine — MC
|
|
|
4
4
|
|
|
5
5
|
> Entries for 2.2.0 through 2.4.0 were not backfilled here; see `docs/sessions/SESSION_19` through `SESSION_21` for those releases.
|
|
6
6
|
|
|
7
|
+
## [2.12.0] 2026-09-27: phase B, second half (liveness, the learnings store, two decisions)
|
|
8
|
+
|
|
9
|
+
### Servers and locks
|
|
10
|
+
|
|
11
|
+
- **A crashed process no longer holds a lock for seconds or minutes.** The audit log, rotation,
|
|
12
|
+
daily-check and learnings-store locks name their holder; a lock whose holder is gone, or an empty lock
|
|
13
|
+
older than a second, is broken at once. A writer killed inside the audit lock used to cost every
|
|
14
|
+
other writer about four entries (it happened on the author's machine on 2026-09-27); a saver killed
|
|
15
|
+
inside the store lock made saves fail for 20 s. Temp copies left by dead writers are removed.
|
|
16
|
+
LOCKs `[A-DEAD-HOLDER-LOSES-THE-LOCK-AT-ONCE]`, `[A-DEAD-STORE-HOLDER-LOSES-THE-LOCK-AT-ONCE]`. (B1-2, B4-2)
|
|
17
|
+
- **A registry record belongs to its own process.** A crashed server's record whose process number was
|
|
18
|
+
reused by another program froze the indexer election and silenced the event port. A record is now
|
|
19
|
+
alive only if its process started no later than the record says. LOCK `[A-RECORD-BELONGS-TO-ITS-OWN-PROCESS]`. (B5-1)
|
|
20
|
+
- **A chat server ends with its chat.** A server whose client died used to live on, still indexing and
|
|
21
|
+
holding the event port. It now stops when its input closes (the launchd agent excepted). A server with
|
|
22
|
+
the embedding model loaded stops without the model runtime's native abort, which used to leave a macOS
|
|
23
|
+
crash report on every stop. LOCK `[A-CHAT-SERVER-ENDS-WITH-ITS-CHAT]`. (B5-2)
|
|
24
|
+
|
|
25
|
+
### Learnings
|
|
26
|
+
|
|
27
|
+
- **A read never writes the store without the lock.** A read that found a bundled default missing used
|
|
28
|
+
to rewrite the whole store unlocked (a concurrent save could be lost), and a deleted default came back at
|
|
29
|
+
the next read. Now the read takes the lock or only shows the default; a deleted default stays deleted,
|
|
30
|
+
and a re-add is recorded. LOCK `[A-READ-NEVER-WRITES-THE-STORE]`. (B4-1)
|
|
31
|
+
|
|
32
|
+
### Firewall and compliance
|
|
33
|
+
|
|
34
|
+
- **A failed `git status` reads "unknown", never "clean"** in the firewall's git check (a repository git
|
|
35
|
+
could not read was reported clean). LOCK `[GIT-FAILURE-IS-UNKNOWN-NOT-CLEAN]`. (B6-2)
|
|
36
|
+
- **`audit-verify` shows every redaction acknowledgement it relied on** (time, actor, reason), and the
|
|
37
|
+
compliance pages say what an acknowledgement cannot prove. Four claims on those pages that had stopped
|
|
38
|
+
being true since June are corrected (rotation, verification, file permissions, restore). (B3-2)
|
|
39
|
+
|
|
7
40
|
## [2.11.0] 2026-09-27: the end-to-end review, phase B (the evidence stays true when things go wrong)
|
|
8
41
|
|
|
9
42
|
Fixes from the end-to-end review, phase B (`docs/audits/E2E_REVIEW_2026-09.md`, section "Phase B").
|
package/dist/audit.d.ts
CHANGED
|
@@ -155,6 +155,16 @@ export interface IntegrityReport {
|
|
|
155
155
|
/** Records whose hash already appeared earlier in the history: a second copy of a record,
|
|
156
156
|
* counted once and never relinked. Not tampering, not a fork. [LOCK] [VERIFY-FORK-IS-NOT-TAMPER] */
|
|
157
157
|
duplicateIndices?: number[];
|
|
158
|
+
/** The acknowledgements that turned altered records into redacted ones: who said so, when, why.
|
|
159
|
+
* An acknowledgement is a statement by whoever ran it, so the verifier shows every one it used.
|
|
160
|
+
* [LOCK] [REDACTION-IS-A-CHAINED-RECORD] */
|
|
161
|
+
acknowledgements?: Array<{
|
|
162
|
+
index: number;
|
|
163
|
+
ts: string;
|
|
164
|
+
actor: string;
|
|
165
|
+
reason: string;
|
|
166
|
+
records: number;
|
|
167
|
+
}>;
|
|
158
168
|
/** Lines that are not records: file, line number, and the history index they sit before.
|
|
159
169
|
* Non-empty makes `ok` false; every other record is still checked. [LOCK] [VERIFY-READS-PAST-AN-UNREADABLE-LINE] */
|
|
160
170
|
unreadable?: UnreadableLine[];
|
package/dist/audit.js
CHANGED
|
@@ -102,14 +102,10 @@ function acquireLockSync() {
|
|
|
102
102
|
// Lockfile exists. Check if it's stale.
|
|
103
103
|
try {
|
|
104
104
|
const st = statSync(path);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
catch {
|
|
111
|
-
/* another process just cleaned it; retry */
|
|
112
|
-
}
|
|
105
|
+
// [LOCK] [A-DEAD-HOLDER-LOSES-THE-LOCK-AT-ONCE]
|
|
106
|
+
if (lockHolderIsGone(path, st) || Date.now() - st.mtimeMs > STALE_LOCK_MS) {
|
|
107
|
+
// Orphaned — force-unlink and retry, only if it is still the file we judged.
|
|
108
|
+
unlinkIfUnchanged(path, st);
|
|
113
109
|
continue;
|
|
114
110
|
}
|
|
115
111
|
}
|
|
@@ -121,6 +117,51 @@ function acquireLockSync() {
|
|
|
121
117
|
}
|
|
122
118
|
throw new Error(`Failed to acquire audit lock at ${path} within ${LOCK_TIMEOUT_MS}ms`);
|
|
123
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* [LOCKED] [A-DEAD-HOLDER-LOSES-THE-LOCK-AT-ONCE] - 2026-09-27
|
|
122
|
+
* [NEVER] make writers wait out STALE_LOCK_MS for a holder that is provably gone.
|
|
123
|
+
* WHY: a writer killed while holding the append lock left it for 10 s; every other writer waited 2 s
|
|
124
|
+
* per entry and lost it (4 per writer per crash, 3 of 3 runs, E2E_REVIEW_2026-09 B1-2; 448 such
|
|
125
|
+
* losses in the launchd log before 2.5.8). It happened again on 2026-09-27 at 17:01Z: a chat
|
|
126
|
+
* server died holding the lock and a new server lost two entries. The lock file names its
|
|
127
|
+
* holder's pid; nothing read it.
|
|
128
|
+
* FIX: a lock whose pid no longer exists, or an empty lock older than a second (its holder died
|
|
129
|
+
* between creating it and writing its pid; a live holder writes it at once), is broken now. A
|
|
130
|
+
* live pid, even a reused one, keeps today's 10 s rule. The file is removed only if it is still
|
|
131
|
+
* the one judged (same inode and time), which narrows the gap where two waiters both break it.
|
|
132
|
+
* [LOCK] [AUDIT-001-WRITE-RACE-FIX]: the lock stays; only the stale test is sharper.
|
|
133
|
+
*/
|
|
134
|
+
function lockHolderIsGone(path, st) {
|
|
135
|
+
let body = "";
|
|
136
|
+
try {
|
|
137
|
+
body = readFileSync(path, "utf-8");
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
const pid = parseInt(body.split("\n")[0], 10);
|
|
143
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
144
|
+
return Date.now() - st.mtimeMs > 1000;
|
|
145
|
+
if (pid === process.pid)
|
|
146
|
+
return false;
|
|
147
|
+
try {
|
|
148
|
+
process.kill(pid, 0);
|
|
149
|
+
return false; // alive (or not ours to signal): wait as before
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
return e.code === "ESRCH";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function unlinkIfUnchanged(path, judged) {
|
|
156
|
+
try {
|
|
157
|
+
const now = statSync(path);
|
|
158
|
+
if (now.ino === judged.ino && now.mtimeMs === judged.mtimeMs)
|
|
159
|
+
unlinkSync(path);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
/* already gone: another waiter broke it */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
124
165
|
function auditDir() {
|
|
125
166
|
// CONTEXTENGINE_HOME lets tests run against a temp dir without touching ~/.contextengine
|
|
126
167
|
return process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine");
|
|
@@ -944,16 +985,18 @@ function acquireRotateLock() {
|
|
|
944
985
|
catch (e) {
|
|
945
986
|
if (e.code !== "EEXIST")
|
|
946
987
|
throw e;
|
|
947
|
-
let
|
|
988
|
+
let st;
|
|
948
989
|
try {
|
|
949
|
-
|
|
990
|
+
st = statSync(lock);
|
|
950
991
|
}
|
|
951
992
|
catch {
|
|
952
993
|
continue; // released between our open and our stat: try again
|
|
953
994
|
}
|
|
954
|
-
|
|
995
|
+
const age = Date.now() - st.mtimeMs;
|
|
996
|
+
// [LOCK] [A-DEAD-HOLDER-LOSES-THE-LOCK-AT-ONCE]: a crashed rotation no longer blocks the next for 10 minutes.
|
|
997
|
+
if (age < ROTATE_LOCK_STALE_MS && !lockHolderIsGone(lock, st))
|
|
955
998
|
return { heldMs: age };
|
|
956
|
-
|
|
999
|
+
unlinkIfUnchanged(lock, st);
|
|
957
1000
|
continue;
|
|
958
1001
|
}
|
|
959
1002
|
try {
|
|
@@ -1298,19 +1341,29 @@ export function verifyChain() {
|
|
|
1298
1341
|
continue;
|
|
1299
1342
|
for (const e of list) {
|
|
1300
1343
|
if (typeof e.hash === "string" && typeof e.content_hash === "string")
|
|
1301
|
-
acks.set(e.hash, e.content_hash);
|
|
1344
|
+
acks.set(e.hash, { contentHash: e.content_hash, ackIndex: i });
|
|
1302
1345
|
}
|
|
1303
1346
|
}
|
|
1304
1347
|
const redacted = [];
|
|
1305
1348
|
const stillTampered = [];
|
|
1349
|
+
const usedAcks = new Map(); // ack record index -> records it covers here
|
|
1306
1350
|
for (const i of tampered) {
|
|
1307
1351
|
const r = records[i];
|
|
1308
1352
|
const bound = acks.get(r.hash);
|
|
1309
|
-
if (bound && bound === computeHash(r.prev_hash, r.ts, r.event, r.actor, r.payload))
|
|
1353
|
+
if (bound && bound.contentHash === computeHash(r.prev_hash, r.ts, r.event, r.actor, r.payload)) {
|
|
1310
1354
|
redacted.push(i);
|
|
1355
|
+
usedAcks.set(bound.ackIndex, (usedAcks.get(bound.ackIndex) ?? 0) + 1);
|
|
1356
|
+
}
|
|
1311
1357
|
else
|
|
1312
1358
|
stillTampered.push(i);
|
|
1313
1359
|
}
|
|
1360
|
+
const acknowledgements = [...usedAcks.entries()].sort((a, b) => a[0] - b[0]).map(([index, n]) => ({
|
|
1361
|
+
index,
|
|
1362
|
+
ts: records[index].ts,
|
|
1363
|
+
actor: records[index].actor,
|
|
1364
|
+
reason: String(records[index].payload.reason ?? ""),
|
|
1365
|
+
records: n,
|
|
1366
|
+
}));
|
|
1314
1367
|
tampered.length = 0;
|
|
1315
1368
|
tampered.push(...stillTampered);
|
|
1316
1369
|
const ok = tampered.length === 0 && orphans.length === 0 && unreadable.length === 0;
|
|
@@ -1334,6 +1387,7 @@ export function verifyChain() {
|
|
|
1334
1387
|
orphanIndices: orphans,
|
|
1335
1388
|
forkIndices: forks,
|
|
1336
1389
|
duplicateIndices: duplicates,
|
|
1390
|
+
acknowledgements,
|
|
1337
1391
|
unreadable,
|
|
1338
1392
|
redactedIndices: redacted,
|
|
1339
1393
|
};
|
|
@@ -1423,14 +1477,17 @@ export function acquireVerifyLock() {
|
|
|
1423
1477
|
catch (e) {
|
|
1424
1478
|
if (e.code !== "EEXIST")
|
|
1425
1479
|
return null;
|
|
1480
|
+
let st;
|
|
1426
1481
|
try {
|
|
1427
|
-
|
|
1428
|
-
return null;
|
|
1482
|
+
st = statSync(lock);
|
|
1429
1483
|
}
|
|
1430
1484
|
catch {
|
|
1431
1485
|
continue;
|
|
1432
1486
|
}
|
|
1433
|
-
|
|
1487
|
+
// [LOCK] [A-DEAD-HOLDER-LOSES-THE-LOCK-AT-ONCE]
|
|
1488
|
+
if (Date.now() - st.mtimeMs < 2 * 3_600_000 && !lockHolderIsGone(lock, st))
|
|
1489
|
+
return null;
|
|
1490
|
+
unlinkIfUnchanged(lock, st);
|
|
1434
1491
|
}
|
|
1435
1492
|
}
|
|
1436
1493
|
return null;
|
package/dist/cli.js
CHANGED
|
@@ -2165,11 +2165,23 @@ async function cliAuditVerify() {
|
|
|
2165
2165
|
console.log(` Counted once. Content intact, nothing missing. Usually a log trim that was interrupted or`);
|
|
2166
2166
|
console.log(` ran while entries arrived. At: ${dups.slice(0, 8).join(", ")}${dups.length > 8 ? `, … (+${dups.length - 8} more)` : ""}`);
|
|
2167
2167
|
};
|
|
2168
|
+
// [LOCK] [REDACTION-IS-A-CHAINED-RECORD]: an acknowledgement is a statement by whoever ran it; show each one used.
|
|
2169
|
+
const acksNote = (log) => {
|
|
2170
|
+
const acks = report.acknowledgements ?? [];
|
|
2171
|
+
if (acks.length === 0)
|
|
2172
|
+
return;
|
|
2173
|
+
log(` Acknowledged by (a statement by whoever ran it; the chain cannot tell a removal from a rewrite):`);
|
|
2174
|
+
for (const a of acks.slice(0, 10))
|
|
2175
|
+
log(` ${a.ts.slice(0, 19).replace("T", " ")}Z ${a.actor} ${a.records} record(s) "${a.reason.slice(0, 80)}"`);
|
|
2176
|
+
if (acks.length > 10)
|
|
2177
|
+
log(` … (+${acks.length - 10} more)`);
|
|
2178
|
+
};
|
|
2168
2179
|
if (report.ok) {
|
|
2169
2180
|
console.log(`✅ Audit chain verified — ${report.total - dups.length} record(s).`);
|
|
2170
2181
|
console.log(redacted.length === 0
|
|
2171
2182
|
? ` No record was altered, and no history is missing.`
|
|
2172
2183
|
: ` No history is missing. ${redacted.length} record(s) redacted and acknowledged on the chain (indices ${redacted.slice(0, 8).join(", ")}${redacted.length > 8 ? ", …" : ""}), 0 altered.`);
|
|
2184
|
+
acksNote((x) => console.log(x));
|
|
2173
2185
|
if (forks.length > 0) {
|
|
2174
2186
|
// [VERIFY-FORK-IS-NOT-TAMPER] — surface this, but do not call it tampering.
|
|
2175
2187
|
console.log(`\n⚠️ ${forks.length} concurrent-append fork(s) detected (not tampering).`);
|
|
@@ -2195,6 +2207,7 @@ async function cliAuditVerify() {
|
|
|
2195
2207
|
}
|
|
2196
2208
|
if (redacted.length > 0) {
|
|
2197
2209
|
console.error(`\n Also ${redacted.length} redacted record(s), acknowledged on the chain, not counted above.`);
|
|
2210
|
+
acksNote((x) => console.error(x));
|
|
2198
2211
|
}
|
|
2199
2212
|
// [LOCK] [VERIFY-READS-PAST-AN-UNREADABLE-LINE]: say where, and that the rest was checked.
|
|
2200
2213
|
const unreadable = report.unreadable ?? [];
|
package/dist/firewall.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// This is the first MCP server that enforces agent behavior
|
|
13
13
|
// through progressive response degradation.
|
|
14
|
-
import { execSync } from "child_process";
|
|
14
|
+
import { execSync, execFileSync } from "child_process";
|
|
15
15
|
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from "fs";
|
|
16
16
|
import { join } from "path";
|
|
17
17
|
import { homedir } from "os";
|
|
@@ -513,19 +513,29 @@ export class ProtocolFirewall {
|
|
|
513
513
|
this.gitCache.timestamp = now;
|
|
514
514
|
this.gitCache.data = [];
|
|
515
515
|
for (const dir of this.projectDirs.slice(0, 5)) {
|
|
516
|
+
// [LOCKED] [GIT-FAILURE-IS-UNKNOWN-NOT-CLEAN] - 2026-09-27 (Yan's GO for this one line, file under DO NOT RE-AUDIT)
|
|
517
|
+
// [NEVER] count dirty files through a shell pipe that turns a failed git into "0".
|
|
518
|
+
// WHY: `git status --porcelain 2>/dev/null | wc -l` printed 0 when git failed, so a repository
|
|
519
|
+
// git could not read (a corrupt index: exit 128, 8 changed files) was reported "Git: ok,
|
|
520
|
+
// clean" (E2E_REVIEW_2026-09 B6-2). [EXEC-FAILURE-IS-NOT-EMPTY] fixed the same shape in agents.ts.
|
|
521
|
+
// FIX: git by argument list, lines counted here; a folder that is not a repository is skipped as
|
|
522
|
+
// before; any other failure (unreadable repo, timeout) is reported as unknown, never clean.
|
|
516
523
|
try {
|
|
517
|
-
const out =
|
|
524
|
+
const out = execFileSync("git", ["status", "--porcelain"], {
|
|
518
525
|
cwd: dir.path,
|
|
519
526
|
encoding: "utf-8",
|
|
520
527
|
timeout: 3000,
|
|
521
|
-
stdio: ["
|
|
522
|
-
})
|
|
523
|
-
const n =
|
|
528
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
529
|
+
});
|
|
530
|
+
const n = out.split("\n").filter(Boolean).length;
|
|
524
531
|
if (n > 0)
|
|
525
532
|
this.gitCache.data.push(`${dir.name}(${n})`);
|
|
526
533
|
}
|
|
527
|
-
catch {
|
|
528
|
-
|
|
534
|
+
catch (e) {
|
|
535
|
+
const stderr = String(e.stderr ?? "");
|
|
536
|
+
if (/not a git repository/i.test(stderr))
|
|
537
|
+
continue;
|
|
538
|
+
this.gitCache.data.push(`${dir.name}(unknown: git could not read it)`);
|
|
529
539
|
}
|
|
530
540
|
}
|
|
531
541
|
}
|
package/dist/index.js
CHANGED
|
@@ -461,6 +461,28 @@ function evaluateRole(reason) {
|
|
|
461
461
|
}
|
|
462
462
|
let healthTick = 0;
|
|
463
463
|
let lastStaleCount = -1;
|
|
464
|
+
let stopRegistry = null;
|
|
465
|
+
let ending = false;
|
|
466
|
+
/**
|
|
467
|
+
* End this server. Everything that matters is already on disk (audit appends and store writes are
|
|
468
|
+
* synchronous); the registry record is removed first. With the embedding model loaded, a normal exit
|
|
469
|
+
* aborts in the model runtime's native teardown ("libc++abi: ... mutex lock failed", SIGABRT, 3 of 3
|
|
470
|
+
* runs, releasing the model does not help) and leaves a macOS crash report per exit, so such a server
|
|
471
|
+
* ends with SIGKILL, which skips that teardown. [LOCK] [A-CHAT-SERVER-ENDS-WITH-ITS-CHAT]
|
|
472
|
+
*/
|
|
473
|
+
function endThisServer() {
|
|
474
|
+
if (ending)
|
|
475
|
+
return;
|
|
476
|
+
ending = true;
|
|
477
|
+
try {
|
|
478
|
+
stopRegistry?.();
|
|
479
|
+
}
|
|
480
|
+
catch { /* the lister removes a dead record anyway */ }
|
|
481
|
+
if (isEmbeddingsReady())
|
|
482
|
+
process.kill(process.pid, "SIGKILL");
|
|
483
|
+
else
|
|
484
|
+
process.exit(0);
|
|
485
|
+
}
|
|
464
486
|
const SERVER_STARTED_AT = Date.now();
|
|
465
487
|
let lastChainCheckSpawn = 0;
|
|
466
488
|
/**
|
|
@@ -1394,7 +1416,8 @@ async function main() {
|
|
|
1394
1416
|
}
|
|
1395
1417
|
}
|
|
1396
1418
|
try {
|
|
1397
|
-
const reg = registerServer({ version: PKG_VERSION, script: fileURLToPath(import.meta.url), corpus, role: corpus ? "reader" : undefined, daemon: process.env.OPSCONTEXT_DAEMON === "1" });
|
|
1419
|
+
const reg = registerServer({ version: PKG_VERSION, script: fileURLToPath(import.meta.url), corpus, role: corpus ? "reader" : undefined, daemon: process.env.OPSCONTEXT_DAEMON === "1", onSignal: endThisServer });
|
|
1420
|
+
stopRegistry = reg.stop;
|
|
1398
1421
|
setRegistryRole = reg.setRole;
|
|
1399
1422
|
setRegistryEventPort = reg.setEventPort;
|
|
1400
1423
|
const fleet = listServers();
|
|
@@ -1453,6 +1476,26 @@ async function main() {
|
|
|
1453
1476
|
const transport = new StdioServerTransport();
|
|
1454
1477
|
await server.connect(transport);
|
|
1455
1478
|
console.error("[ContextEngine] 🚀 MCP server running on stdio (keyword search ready)");
|
|
1479
|
+
// [LOCKED] [A-CHAT-SERVER-ENDS-WITH-ITS-CHAT] - 2026-09-27
|
|
1480
|
+
// [NEVER] let a server whose client closed its stdin keep running, unless it is the launchd agent.
|
|
1481
|
+
// WHY: the file watchers and the event receiver keep the event loop alive, and nothing listened for
|
|
1482
|
+
// the end of stdin. When a chat's client died without stopping its server (3 of 3 runs,
|
|
1483
|
+
// E2E_REVIEW_2026-09 B5-2), the server lived on, adopted by launchd, still the indexer, still
|
|
1484
|
+
// holding the event port, running its build for good; new servers became its readers.
|
|
1485
|
+
// FIX: stdin's end or close ends a chat server (its registry record goes with it, and a reader takes
|
|
1486
|
+
// over the index within one role poll, 15 s). The launchd agent (OPSCONTEXT_DAEMON=1) has
|
|
1487
|
+
// stdin on /dev/null by design and is exempt. [LOCK] [AUTOSTART-IS-THE-STANDING-INDEXER]
|
|
1488
|
+
// Ending goes through endThisServer(): with the embedding model loaded, a normal exit aborts in
|
|
1489
|
+
// native code and leaves a macOS crash report each time, so such a server ends with SIGKILL
|
|
1490
|
+
// after its record is removed; the stop signals (SIGTERM, SIGINT, SIGHUP) take the same path.
|
|
1491
|
+
if (process.env.OPSCONTEXT_DAEMON !== "1") {
|
|
1492
|
+
const leave = () => {
|
|
1493
|
+
console.error("[ContextEngine] 👋 the client closed the connection: this server stops");
|
|
1494
|
+
endThisServer();
|
|
1495
|
+
};
|
|
1496
|
+
process.stdin.once("end", leave);
|
|
1497
|
+
process.stdin.once("close", leave);
|
|
1498
|
+
}
|
|
1456
1499
|
// 3a. Audit log auto-rotation. Deferred so the first requests are answered before the
|
|
1457
1500
|
// synchronous verify + rewrite (a few seconds on a 500k-record chain) blocks the loop.
|
|
1458
1501
|
// [LOCK] [AUTO-ROTATE-HYSTERESIS-AND-ONE-RUNNER]
|
package/dist/learnings.d.ts
CHANGED
|
@@ -15,6 +15,9 @@ export interface LearningsStore {
|
|
|
15
15
|
version: number;
|
|
16
16
|
count: number;
|
|
17
17
|
learnings: Learning[];
|
|
18
|
+
/** Bundled default rules (lowercased, trimmed) the owner deleted: never merged back.
|
|
19
|
+
* [LOCK] [A-READ-NEVER-WRITES-THE-STORE] */
|
|
20
|
+
dismissed_defaults?: string[];
|
|
18
21
|
}
|
|
19
22
|
/** Valid categories for learnings */
|
|
20
23
|
export declare const LEARNING_CATEGORIES: readonly ["deployment", "api", "database", "frontend", "backend", "devops", "security", "performance", "testing", "debugging", "tooling", "git", "dependencies", "architecture", "data", "infrastructure", "mobile", "other"];
|
package/dist/learnings.js
CHANGED
|
@@ -76,10 +76,13 @@ function loadBundledDefaults() {
|
|
|
76
76
|
function mergeDefaults(store) {
|
|
77
77
|
const bundled = loadBundledDefaults();
|
|
78
78
|
if (bundled.length === 0)
|
|
79
|
-
return
|
|
79
|
+
return 0;
|
|
80
80
|
const existingRules = new Set(store.learnings
|
|
81
81
|
.filter((l) => typeof l.rule === "string")
|
|
82
82
|
.map((l) => l.rule.toLowerCase().trim()));
|
|
83
|
+
// A default the owner deleted stays deleted. [LOCK] [A-READ-NEVER-WRITES-THE-STORE]
|
|
84
|
+
for (const r of store.dismissed_defaults ?? [])
|
|
85
|
+
existingRules.add(r);
|
|
83
86
|
let added = 0;
|
|
84
87
|
const now = new Date().toISOString();
|
|
85
88
|
for (const def of bundled) {
|
|
@@ -97,7 +100,12 @@ function mergeDefaults(store) {
|
|
|
97
100
|
existingRules.add(def.rule.toLowerCase().trim());
|
|
98
101
|
added++;
|
|
99
102
|
}
|
|
100
|
-
return added
|
|
103
|
+
return added;
|
|
104
|
+
}
|
|
105
|
+
/** Is this rule text one of the bundled defaults? */
|
|
106
|
+
function isBundledDefault(rule) {
|
|
107
|
+
const key = rule.toLowerCase().trim();
|
|
108
|
+
return loadBundledDefaults().some((d) => d.rule.toLowerCase().trim() === key);
|
|
101
109
|
}
|
|
102
110
|
// [LOCKED] [STORE-NEVER-STARTS-FRESH-OVER-DATA] 2026-09-05
|
|
103
111
|
// [NEVER] turn an unreadable learnings.json into an empty store, write the store with a
|
|
@@ -127,6 +135,55 @@ let batchDirty = false;
|
|
|
127
135
|
function sleepMs(ms) {
|
|
128
136
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
129
137
|
}
|
|
138
|
+
// [LOCKED] [A-DEAD-STORE-HOLDER-LOSES-THE-LOCK-AT-ONCE] 2026-09-27
|
|
139
|
+
// [NEVER] make a save wait out LOCK_STALE_MS for a holder that is provably gone, or leave a dead
|
|
140
|
+
// writer's full copy of the store on disk.
|
|
141
|
+
// WHY: a saver killed inside the lock made every other save in the next ~20 s wait 10 s and fail
|
|
142
|
+
// with "locked by another process" (6 per run, 3 of 3, E2E_REVIEW_2026-09 B4-2), and left its
|
|
143
|
+
// temp copy of the whole store behind for good. The lock directory names its holder's pid;
|
|
144
|
+
// nothing read it.
|
|
145
|
+
// FIX: a lock whose pid no longer exists, or a lock directory older than a second with no pid in
|
|
146
|
+
// it, is taken over at once; a live pid keeps the 30 s rule. Inside the lock, temp copies
|
|
147
|
+
// written by a pid that no longer exists are removed.
|
|
148
|
+
function storeHolderIsGone(age) {
|
|
149
|
+
let pid = NaN;
|
|
150
|
+
try {
|
|
151
|
+
pid = parseInt(readFileSync(join(STORE_LOCK_DIR, "pid"), "utf-8"), 10);
|
|
152
|
+
}
|
|
153
|
+
catch { /* no pid yet */ }
|
|
154
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
155
|
+
return age > 1000;
|
|
156
|
+
if (pid === process.pid)
|
|
157
|
+
return false;
|
|
158
|
+
try {
|
|
159
|
+
process.kill(pid, 0);
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
return e.code === "ESRCH";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function removeDeadWritersTemps() {
|
|
167
|
+
try {
|
|
168
|
+
const dir = dirname(LEARNINGS_PATH);
|
|
169
|
+
for (const f of readdirSync(dir)) {
|
|
170
|
+
const m = /^learnings\.json\.tmp-(\d+)-\d+$/.exec(f);
|
|
171
|
+
if (!m)
|
|
172
|
+
continue;
|
|
173
|
+
const pid = Number(m[1]);
|
|
174
|
+
if (pid === process.pid)
|
|
175
|
+
continue;
|
|
176
|
+
try {
|
|
177
|
+
process.kill(pid, 0);
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
if (e.code === "ESRCH")
|
|
181
|
+
unlinkSync(join(dir, f));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch { /* cleanup is a courtesy; the store is untouched either way */ }
|
|
186
|
+
}
|
|
130
187
|
/** Cross-process, re-entrant (within this process) lock around the store file. */
|
|
131
188
|
export function withStoreLock(fn) {
|
|
132
189
|
if (lockDepth > 0) {
|
|
@@ -160,7 +217,8 @@ export function withStoreLock(fn) {
|
|
|
160
217
|
catch {
|
|
161
218
|
age = 0;
|
|
162
219
|
}
|
|
163
|
-
|
|
220
|
+
// [LOCK] [A-DEAD-STORE-HOLDER-LOSES-THE-LOCK-AT-ONCE]
|
|
221
|
+
if (age > LOCK_STALE_MS || storeHolderIsGone(age)) {
|
|
164
222
|
// Holder died (or hung) without releasing: take it over.
|
|
165
223
|
try {
|
|
166
224
|
rmSync(STORE_LOCK_DIR, { recursive: true, force: true });
|
|
@@ -176,6 +234,7 @@ export function withStoreLock(fn) {
|
|
|
176
234
|
}
|
|
177
235
|
lockDepth = 1;
|
|
178
236
|
try {
|
|
237
|
+
removeDeadWritersTemps();
|
|
179
238
|
return fn();
|
|
180
239
|
}
|
|
181
240
|
finally {
|
|
@@ -237,14 +296,42 @@ function readStoreFromDisk() {
|
|
|
237
296
|
else {
|
|
238
297
|
store = { version: 1, count: 0, learnings: [] };
|
|
239
298
|
}
|
|
240
|
-
//
|
|
241
|
-
|
|
299
|
+
// [LOCKED] [A-READ-NEVER-WRITES-THE-STORE] - 2026-09-27
|
|
300
|
+
// [NEVER] write the store from a read that does not hold the store lock, and never merge back a
|
|
301
|
+
// bundled default the owner deleted.
|
|
302
|
+
// WHY: a plain read (listLearnings, searchLearnings, the index build) merged a missing bundled
|
|
303
|
+
// default and wrote the whole store WITHOUT the lock. With that read held 1.5 s before its
|
|
304
|
+
// write, a concurrent saver's 5 learnings, each saved with success, were gone (3 of 3,
|
|
305
|
+
// E2E_REVIEW_2026-09 B4-1); at natural speed the window is milliseconds and opens after every
|
|
306
|
+
// release that adds a default. And a deleted default came back at the next read, with a new id
|
|
307
|
+
// and no audit record: the delete never stuck. [LOCK] [STORE-NEVER-STARTS-FRESH-OVER-DATA]
|
|
308
|
+
// already said "never let two processes write it without the lock".
|
|
309
|
+
// FIX: holding the lock (a save, a delete, a batch), the merge is written as before. A plain read
|
|
310
|
+
// takes the lock for it (re-reading inside), and if the lock is busy only shows the defaults,
|
|
311
|
+
// writing nothing. A deleted default is remembered in `dismissed_defaults`; every persisted
|
|
312
|
+
// merge is recorded as a learning.import of the bundled defaults.
|
|
313
|
+
if (!hasMissingDefaults(store))
|
|
314
|
+
return store;
|
|
315
|
+
if (lockDepth > 0) {
|
|
316
|
+
const added = mergeDefaults(store);
|
|
242
317
|
if (batchStore)
|
|
243
318
|
batchDirty = true;
|
|
244
319
|
else
|
|
245
320
|
writeStoreToDisk(store);
|
|
321
|
+
safeAppend("learning.import", { source: "bundled defaults", format: "json", imported: added, updated: 0, skipped: 0 });
|
|
322
|
+
return store;
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
return withStoreLock(() => readStoreFromDisk());
|
|
246
326
|
}
|
|
247
|
-
|
|
327
|
+
catch {
|
|
328
|
+
mergeDefaults(store); // the lock is busy: show them, persist at the next locked read or write
|
|
329
|
+
return store;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function hasMissingDefaults(store) {
|
|
333
|
+
const probe = { version: store.version, count: 0, learnings: store.learnings.slice(), dismissed_defaults: store.dismissed_defaults };
|
|
334
|
+
return mergeDefaults(probe) > 0;
|
|
248
335
|
}
|
|
249
336
|
function loadStore() {
|
|
250
337
|
if (batchStore)
|
|
@@ -517,12 +604,19 @@ function deleteLearningUnlocked(id) {
|
|
|
517
604
|
return false;
|
|
518
605
|
const removed = store.learnings[index];
|
|
519
606
|
store.learnings.splice(index, 1);
|
|
607
|
+
// A deleted bundled default stays deleted. [LOCK] [A-READ-NEVER-WRITES-THE-STORE]
|
|
608
|
+
const dismissed = typeof removed.rule === "string" && isBundledDefault(removed.rule);
|
|
609
|
+
if (dismissed) {
|
|
610
|
+
const key = removed.rule.toLowerCase().trim();
|
|
611
|
+
store.dismissed_defaults = [...new Set([...(store.dismissed_defaults ?? []), key])];
|
|
612
|
+
}
|
|
520
613
|
saveStore(store);
|
|
521
614
|
safeAppend("learning.delete", {
|
|
522
615
|
id: removed.id,
|
|
523
616
|
category: removed.category,
|
|
524
617
|
project: removed.project,
|
|
525
618
|
rule_length: typeof removed.rule === "string" ? removed.rule.length : 0,
|
|
619
|
+
...(dismissed ? { default_dismissed: true } : {}),
|
|
526
620
|
});
|
|
527
621
|
return true;
|
|
528
622
|
}
|
|
@@ -42,6 +42,8 @@ export declare const SERVER_COUNT_WARN = 3;
|
|
|
42
42
|
*/
|
|
43
43
|
export declare function buildHashOf(scriptPath: string): string | null;
|
|
44
44
|
export declare function isAlive(pid: number): boolean;
|
|
45
|
+
/** The record's own process is still running. [LOCK] [A-RECORD-BELONGS-TO-ITS-OWN-PROCESS] */
|
|
46
|
+
export declare function recordIsLive(rec: Pick<ServerRecord, "pid" | "started">): boolean;
|
|
45
47
|
/**
|
|
46
48
|
* Register the running server. Returns a stop() that removes the record; exit handlers call it too.
|
|
47
49
|
*/
|
|
@@ -51,6 +53,7 @@ export declare function registerServer(opts: {
|
|
|
51
53
|
corpus?: string;
|
|
52
54
|
role?: "indexer" | "reader";
|
|
53
55
|
daemon?: boolean;
|
|
56
|
+
onSignal?: () => void;
|
|
54
57
|
}): {
|
|
55
58
|
record: ServerRecord;
|
|
56
59
|
stop: () => void;
|
package/dist/server-registry.js
CHANGED
|
@@ -76,6 +76,60 @@ export function isAlive(pid) {
|
|
|
76
76
|
return e?.code === "EPERM"; // exists, not ours
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* [LOCKED] [A-RECORD-BELONGS-TO-ITS-OWN-PROCESS] - 2026-09-27
|
|
81
|
+
* [NEVER] treat a registry record as a live server because its pid exists.
|
|
82
|
+
* WHY: pids are reused. A dead launchd agent's record pointed at a live unrelated process (3 of 3
|
|
83
|
+
* valid runs, E2E_REVIEW_2026-09 B5-1): for as long as that process lived, no reader took over
|
|
84
|
+
* indexing (a doc change never reached the index), nobody answered on the event port, a new chat
|
|
85
|
+
* server did not take it, and `contextengine servers` listed the unrelated process as the
|
|
86
|
+
* current launchd agent holding the port. Records survive a crash or a power cut, and pids wrap.
|
|
87
|
+
* FIX: a record is alive when its pid exists AND that process started no later than the record says
|
|
88
|
+
* the server did (the server registers after its process starts; a reused pid belongs to a
|
|
89
|
+
* process that started later). Start times come from one `ps -o pid=,lstart=` for all uncached
|
|
90
|
+
* pids of a listing, cached 60 s per pid. When ps cannot answer, the pid test alone decides, as
|
|
91
|
+
* before. A record that fails is dead: removed by the lister, never elected, never the daemon.
|
|
92
|
+
*/
|
|
93
|
+
const START_CACHE_MS = 60_000;
|
|
94
|
+
const startCache = new Map();
|
|
95
|
+
function refreshStartTimes(pids) {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const need = pids.filter((p) => { const c = startCache.get(p); return !c || now - c.at > START_CACHE_MS; });
|
|
98
|
+
if (need.length === 0)
|
|
99
|
+
return;
|
|
100
|
+
let out = "";
|
|
101
|
+
try {
|
|
102
|
+
// Hardcoded argv, no shell: the only variables are numbers.
|
|
103
|
+
out = execFileSync("ps", ["-o", "pid=,lstart=", "-p", need.join(",")], { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] });
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
const stdout = e.stdout;
|
|
107
|
+
out = typeof stdout === "string" ? stdout : ""; // ps exits 1 when some pid is gone; keep what it printed
|
|
108
|
+
}
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
for (const line of out.split("\n")) {
|
|
111
|
+
const m = /^\s*(\d+)\s+(.+?)\s*$/.exec(line);
|
|
112
|
+
if (!m)
|
|
113
|
+
continue;
|
|
114
|
+
const t = Date.parse(m[2]);
|
|
115
|
+
seen.add(Number(m[1]));
|
|
116
|
+
startCache.set(Number(m[1]), { startedMs: Number.isNaN(t) ? null : t, at: now });
|
|
117
|
+
}
|
|
118
|
+
for (const p of need)
|
|
119
|
+
if (!seen.has(p))
|
|
120
|
+
startCache.set(p, { startedMs: null, at: now });
|
|
121
|
+
}
|
|
122
|
+
/** The record's own process is still running. [LOCK] [A-RECORD-BELONGS-TO-ITS-OWN-PROCESS] */
|
|
123
|
+
export function recordIsLive(rec) {
|
|
124
|
+
if (!isAlive(rec.pid))
|
|
125
|
+
return false;
|
|
126
|
+
refreshStartTimes([rec.pid]);
|
|
127
|
+
const startedMs = startCache.get(rec.pid)?.startedMs ?? null;
|
|
128
|
+
const registered = Date.parse(rec.started);
|
|
129
|
+
if (startedMs === null || Number.isNaN(registered))
|
|
130
|
+
return true; // cannot tell: the pid test decides
|
|
131
|
+
return startedMs <= registered + 5_000; // lstart has one-second resolution
|
|
132
|
+
}
|
|
79
133
|
/**
|
|
80
134
|
* Register the running server. Returns a stop() that removes the record; exit handlers call it too.
|
|
81
135
|
*/
|
|
@@ -119,7 +173,11 @@ export function registerServer(opts) {
|
|
|
119
173
|
};
|
|
120
174
|
process.on("exit", stop);
|
|
121
175
|
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
|
|
122
|
-
|
|
176
|
+
// The record goes first; the caller may end the process its own way. [LOCK] [A-CHAT-SERVER-ENDS-WITH-ITS-CHAT]
|
|
177
|
+
process.on(sig, () => { stop(); if (opts.onSignal)
|
|
178
|
+
opts.onSignal();
|
|
179
|
+
else
|
|
180
|
+
process.exit(0); });
|
|
123
181
|
}
|
|
124
182
|
const setRole = (role) => { record.role = role; write(); };
|
|
125
183
|
const setEventPort = (port) => {
|
|
@@ -141,7 +199,7 @@ export function liveDaemonPid(exceptPid = process.pid) {
|
|
|
141
199
|
continue;
|
|
142
200
|
try {
|
|
143
201
|
const rec = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
144
|
-
if (rec.daemon && rec.pid !== exceptPid &&
|
|
202
|
+
if (rec.daemon && rec.pid !== exceptPid && recordIsLive(rec))
|
|
145
203
|
return rec.pid;
|
|
146
204
|
}
|
|
147
205
|
catch {
|
|
@@ -156,6 +214,7 @@ export function listServers() {
|
|
|
156
214
|
const report = { servers: [], removed: 0, warnings: [] };
|
|
157
215
|
if (!existsSync(dir))
|
|
158
216
|
return report;
|
|
217
|
+
const records = [];
|
|
159
218
|
for (const f of readdirSync(dir)) {
|
|
160
219
|
if (!f.endsWith(".json"))
|
|
161
220
|
continue;
|
|
@@ -172,7 +231,12 @@ export function listServers() {
|
|
|
172
231
|
report.removed++;
|
|
173
232
|
continue;
|
|
174
233
|
}
|
|
175
|
-
|
|
234
|
+
records.push({ path, rec });
|
|
235
|
+
}
|
|
236
|
+
refreshStartTimes(records.filter(({ rec }) => isAlive(rec.pid)).map(({ rec }) => rec.pid)); // one ps for the listing
|
|
237
|
+
for (const { path, rec } of records) {
|
|
238
|
+
// [LOCK] [A-RECORD-BELONGS-TO-ITS-OWN-PROCESS]
|
|
239
|
+
if (!recordIsLive(rec)) {
|
|
176
240
|
try {
|
|
177
241
|
unlinkSync(path);
|
|
178
242
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compr/opscontext-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "OpsContext for AI Agents — read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|