@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 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
- if (Date.now() - st.mtimeMs > STALE_LOCK_MS) {
106
- // Orphaned — force-unlink and retry.
107
- try {
108
- unlinkSync(path);
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 age;
988
+ let st;
948
989
  try {
949
- age = Date.now() - statSync(lock).mtimeMs;
990
+ st = statSync(lock);
950
991
  }
951
992
  catch {
952
993
  continue; // released between our open and our stat: try again
953
994
  }
954
- if (age < ROTATE_LOCK_STALE_MS)
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
- safeUnlink(lock);
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
- if (Date.now() - statSync(lock).mtimeMs < 2 * 3_600_000)
1428
- return null;
1482
+ st = statSync(lock);
1429
1483
  }
1430
1484
  catch {
1431
1485
  continue;
1432
1486
  }
1433
- safeUnlink(lock);
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 = execSync("git status --porcelain 2>/dev/null | wc -l", {
524
+ const out = execFileSync("git", ["status", "--porcelain"], {
518
525
  cwd: dir.path,
519
526
  encoding: "utf-8",
520
527
  timeout: 3000,
521
- stdio: ["pipe", "pipe", "pipe"],
522
- }).trim();
523
- const n = parseInt(out);
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
- /* skip */
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]
@@ -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 false;
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 > 0;
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
- if (age > LOCK_STALE_MS) {
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
- // Auto-merge bundled defaults on first load or when new defaults are added
241
- if (mergeDefaults(store)) {
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
- return store;
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;
@@ -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
- process.on(sig, () => { stop(); process.exit(0); });
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 && isAlive(rec.pid))
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
- if (!isAlive(rec.pid)) {
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.11.0",
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",