@tangle-network/agent-knowledge 22.0.8 → 22.0.9
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 +4 -0
- package/dist/cli.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -4
- package/dist/index.js.map +1 -1
- package/dist/{inspect-Ct0VmbIZ.js → inspect-CTpCsUB2.js} +253 -244
- package/dist/inspect-CTpCsUB2.js.map +1 -0
- package/docs/architecture.md +2 -0
- package/package.json +1 -1
- package/dist/inspect-Ct0VmbIZ.js.map +0 -1
|
@@ -7,8 +7,8 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
7
7
|
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
|
|
8
8
|
import { lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, rmdir } from "node:fs/promises";
|
|
9
9
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
10
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
10
|
import { hostname } from "node:os";
|
|
11
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12
12
|
//#region src/adapters.ts
|
|
13
13
|
const textSourceAdapter = {
|
|
14
14
|
id: "text",
|
|
@@ -401,6 +401,253 @@ function normalizePagesDirectory(value = DEFAULT_PAGES_DIRECTORY) {
|
|
|
401
401
|
return normalized;
|
|
402
402
|
}
|
|
403
403
|
//#endregion
|
|
404
|
+
//#region src/lock-holder.ts
|
|
405
|
+
const DEFAULT_STALE_MS = 9e5;
|
|
406
|
+
/** The lease a caller's `staleMs` resolves to: the default, floored at 5 s. */
|
|
407
|
+
function staleWindow(staleMs) {
|
|
408
|
+
return Math.max(5e3, staleMs ?? 9e5);
|
|
409
|
+
}
|
|
410
|
+
/** The store's cross-process lock, inside the cache directory. */
|
|
411
|
+
const STORE_LOCK = {
|
|
412
|
+
directory: "mutation.lock.durable",
|
|
413
|
+
owner: "mutation.lock.owner.json",
|
|
414
|
+
guard: "mutation.lock.takeover",
|
|
415
|
+
noun: "knowledge store lock"
|
|
416
|
+
};
|
|
417
|
+
/** One knowledge-improvement run's lease, inside its run directory. */
|
|
418
|
+
const RUN_LEASE = {
|
|
419
|
+
directory: "run.lock.durable",
|
|
420
|
+
owner: "run.lock.owner.json",
|
|
421
|
+
guard: "run.lock.takeover",
|
|
422
|
+
noun: "knowledge improvement run lease"
|
|
423
|
+
};
|
|
424
|
+
/** A broken lock directory is renamed to `<lock>.dead-<uuid>` beside it, then removed. */
|
|
425
|
+
const TOMBSTONE_INFIX = ".dead-";
|
|
426
|
+
/** How far before `staleMs` a takeover yields to proper-lockfile's own stale removal. */
|
|
427
|
+
const STALE_MARGIN_MS = 6e4;
|
|
428
|
+
function describeHolder(holder, now = Date.now()) {
|
|
429
|
+
const heldS = Math.max(0, Math.round((now - holder.since) / 1e3));
|
|
430
|
+
return `${holder.label} (pid ${holder.pid} on ${holder.host}, holding since ${new Date(holder.since).toISOString()}, ${heldS} s)`;
|
|
431
|
+
}
|
|
432
|
+
let lockfileModule;
|
|
433
|
+
function loadLockfile() {
|
|
434
|
+
lockfileModule ??= import("proper-lockfile");
|
|
435
|
+
return lockfileModule;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* The OS start token of a live pid, the way agent-runtime's `readProcessStart` reads it: the same
|
|
439
|
+
* for a process's whole life and different for every later holder of its pid. `undefined` when
|
|
440
|
+
* the host cannot report one. `node:child_process` loads lazily because the package root, which
|
|
441
|
+
* Cloudflare consumers bundle, imports this module.
|
|
442
|
+
*/
|
|
443
|
+
async function readProcessStart(pid) {
|
|
444
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || process.platform === "win32") return void 0;
|
|
445
|
+
if (process.platform === "linux") {
|
|
446
|
+
let stat;
|
|
447
|
+
try {
|
|
448
|
+
stat = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
449
|
+
} catch {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const startTime = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/u)[19];
|
|
453
|
+
return startTime !== void 0 && /^\d+$/u.test(startTime) ? startTime : void 0;
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
const { execFile } = await import("node:child_process");
|
|
457
|
+
return (await new Promise((resolve, reject) => execFile("ps", [
|
|
458
|
+
"-o",
|
|
459
|
+
"lstart=",
|
|
460
|
+
"-p",
|
|
461
|
+
String(pid)
|
|
462
|
+
], (error, out) => error ? reject(error) : resolve(out)))).trim() || void 0;
|
|
463
|
+
} catch {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
let ownIdentity;
|
|
468
|
+
function readOwnIdentity() {
|
|
469
|
+
ownIdentity ??= (async () => {
|
|
470
|
+
const [processStart, bootId, pidNamespace] = await Promise.all([
|
|
471
|
+
readProcessStart(process.pid),
|
|
472
|
+
readFile("/proc/sys/kernel/random/boot_id", "utf8").then((text) => text.trim() || void 0, () => void 0),
|
|
473
|
+
readlink("/proc/self/ns/pid").catch(() => void 0)
|
|
474
|
+
]);
|
|
475
|
+
return {
|
|
476
|
+
...processStart ? { processStart } : {},
|
|
477
|
+
...bootId ? { bootId } : {},
|
|
478
|
+
...pidNamespace ? { pidNamespace } : {}
|
|
479
|
+
};
|
|
480
|
+
})();
|
|
481
|
+
return ownIdentity;
|
|
482
|
+
}
|
|
483
|
+
async function readLockDirectoryIdentity(cacheDir, held) {
|
|
484
|
+
try {
|
|
485
|
+
const entry = await lstat(join(cacheDir, held.directory), { bigint: true });
|
|
486
|
+
if (!entry.isDirectory()) return void 0;
|
|
487
|
+
return {
|
|
488
|
+
dev: entry.dev.toString(),
|
|
489
|
+
ino: entry.ino.toString(),
|
|
490
|
+
birthtimeNs: entry.birthtimeNs.toString()
|
|
491
|
+
};
|
|
492
|
+
} catch {
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Record who holds the file lock, and which lock directory, inside the directory that holds it.
|
|
498
|
+
* Call it right after acquiring. Exported for the confinement tests; src/index.ts does not
|
|
499
|
+
* re-export it.
|
|
500
|
+
*/
|
|
501
|
+
async function recordLockOwner(cacheDir, label, held = STORE_LOCK) {
|
|
502
|
+
try {
|
|
503
|
+
const [identity, lock] = await Promise.all([readOwnIdentity(), readLockDirectoryIdentity(cacheDir, held)]);
|
|
504
|
+
const owner = {
|
|
505
|
+
label,
|
|
506
|
+
pid: process.pid,
|
|
507
|
+
host: hostname(),
|
|
508
|
+
since: Date.now(),
|
|
509
|
+
...identity,
|
|
510
|
+
...lock ? { lock } : {}
|
|
511
|
+
};
|
|
512
|
+
await writeJsonDurableWithinRoot(cacheDir, held.owner, owner);
|
|
513
|
+
} catch {}
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* `cacheDir` is withSafeDirectory's anchored path, so only the owner file itself can be swapped.
|
|
517
|
+
* Anyone who can write the cache directory can plant a FIFO there, so the open adds O_NONBLOCK
|
|
518
|
+
* and fstat refuses it. The shared readRegularFileNoFollow stays blocking: the event store and
|
|
519
|
+
* file transactions read through it, and their descriptor use must not change.
|
|
520
|
+
*
|
|
521
|
+
* The old shape `{ label, pid, host, since }` still parses. A newer field that is missing or
|
|
522
|
+
* malformed is dropped, which leaves the holder named but never provably dead.
|
|
523
|
+
*/
|
|
524
|
+
async function readLockOwner(cacheDir, held = STORE_LOCK) {
|
|
525
|
+
let handle;
|
|
526
|
+
try {
|
|
527
|
+
handle = await open(join(cacheDir, held.owner), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
528
|
+
if (!(await handle.stat()).isFile()) return void 0;
|
|
529
|
+
const value = JSON.parse(await handle.readFile("utf8"));
|
|
530
|
+
if (typeof value?.label !== "string" || !Number.isSafeInteger(value.pid) || typeof value.host !== "string" || typeof value.since !== "number" || Number.isNaN(new Date(value.since).getTime())) return;
|
|
531
|
+
const text = (field) => typeof field === "string" && field.length > 0 ? field : void 0;
|
|
532
|
+
const lock = value.lock;
|
|
533
|
+
const decimal = (field) => typeof field === "string" && /^\d+$/u.test(field);
|
|
534
|
+
const processStart = text(value.processStart);
|
|
535
|
+
const bootId = text(value.bootId);
|
|
536
|
+
const pidNamespace = text(value.pidNamespace);
|
|
537
|
+
return {
|
|
538
|
+
label: value.label,
|
|
539
|
+
pid: value.pid,
|
|
540
|
+
host: value.host,
|
|
541
|
+
since: value.since,
|
|
542
|
+
...processStart ? { processStart } : {},
|
|
543
|
+
...bootId ? { bootId } : {},
|
|
544
|
+
...pidNamespace ? { pidNamespace } : {},
|
|
545
|
+
...lock && decimal(lock.dev) && decimal(lock.ino) && decimal(lock.birthtimeNs) ? { lock: {
|
|
546
|
+
dev: lock.dev,
|
|
547
|
+
ino: lock.ino,
|
|
548
|
+
birthtimeNs: lock.birthtimeNs
|
|
549
|
+
} } : {}
|
|
550
|
+
};
|
|
551
|
+
} catch {
|
|
552
|
+
return;
|
|
553
|
+
} finally {
|
|
554
|
+
await handle?.close().catch(() => void 0);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/** Only ESRCH proves a pid gone; EPERM means it exists under another user. */
|
|
558
|
+
function pidIsGone(pid) {
|
|
559
|
+
try {
|
|
560
|
+
process.kill(pid, 0);
|
|
561
|
+
return false;
|
|
562
|
+
} catch (error) {
|
|
563
|
+
return error.code === "ESRCH";
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* The holder of the current lock, when its record proves it dead; otherwise `undefined`,
|
|
568
|
+
* and the caller keeps the `staleMs` lease. Proof needs a record with every identity field,
|
|
569
|
+
* written for this very lock directory (dev, ino and a nonzero birthtime), on this host and pid
|
|
570
|
+
* namespace, and then a rebooted host, a gone pid, or a pid whose start token changed. Anything
|
|
571
|
+
* else is unknown, so a live holder's lease is never shortened.
|
|
572
|
+
*/
|
|
573
|
+
async function holderIsProvablyDead(cacheDir, held = STORE_LOCK) {
|
|
574
|
+
const holder = await readLockOwner(cacheDir, held);
|
|
575
|
+
if (!holder?.processStart || !holder.lock || holder.pid <= 0) return void 0;
|
|
576
|
+
const lock = await readLockDirectoryIdentity(cacheDir, held);
|
|
577
|
+
if (!lock || lock.birthtimeNs === "0" || lock.dev !== holder.lock.dev || lock.ino !== holder.lock.ino || lock.birthtimeNs !== holder.lock.birthtimeNs || holder.host !== hostname()) return;
|
|
578
|
+
const own = await readOwnIdentity();
|
|
579
|
+
if (holder.pidNamespace !== own.pidNamespace) return void 0;
|
|
580
|
+
if (holder.bootId === void 0 !== (own.bootId === void 0)) return void 0;
|
|
581
|
+
if (holder.bootId !== own.bootId) return {
|
|
582
|
+
holder,
|
|
583
|
+
lock,
|
|
584
|
+
reason: "host rebooted"
|
|
585
|
+
};
|
|
586
|
+
if (pidIsGone(holder.pid)) return {
|
|
587
|
+
holder,
|
|
588
|
+
lock,
|
|
589
|
+
reason: "pid gone"
|
|
590
|
+
};
|
|
591
|
+
const start = await readProcessStart(holder.pid);
|
|
592
|
+
if (start !== void 0 && start !== holder.processStart) return {
|
|
593
|
+
holder,
|
|
594
|
+
lock,
|
|
595
|
+
reason: "pid reused"
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Break the lock of a provably dead holder, and report whether this call broke it. The
|
|
600
|
+
* guard is proper-lockfile itself: a taker that finds it held breaks nothing and returns to its
|
|
601
|
+
* wait, because another taker is already breaking the lock. Its target is the guard path, not the
|
|
602
|
+
* locked target, because proper-lockfile keys its held locks by target and a second lock on that
|
|
603
|
+
* target would replace this process's entry for it and break that lock's heartbeat and release.
|
|
604
|
+
*
|
|
605
|
+
* proper-lockfile's own stale removal does not take the guard. Once the lock directory's mtime is
|
|
606
|
+
* near `staleMs`, another process may remove it as stale and create its own between our re-check
|
|
607
|
+
* and our rename, so that lock is left to the stale path, which frees it within the margin.
|
|
608
|
+
*/
|
|
609
|
+
async function takeOverDeadHolder(cacheDir, staleMs, held = STORE_LOCK) {
|
|
610
|
+
if (!await holderIsProvablyDead(cacheDir, held)) return false;
|
|
611
|
+
const { lock } = await loadLockfile();
|
|
612
|
+
const guard = join(cacheDir, held.guard);
|
|
613
|
+
let release;
|
|
614
|
+
try {
|
|
615
|
+
release = await lock(guard, {
|
|
616
|
+
lockfilePath: guard,
|
|
617
|
+
realpath: false,
|
|
618
|
+
stale: 3e4,
|
|
619
|
+
retries: 0,
|
|
620
|
+
onCompromised: () => void 0
|
|
621
|
+
});
|
|
622
|
+
} catch (error) {
|
|
623
|
+
if (error.code === "ELOCKED") return false;
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
const dead = await holderIsProvablyDead(cacheDir, held);
|
|
628
|
+
if (!dead) return false;
|
|
629
|
+
const lockDirectory = join(cacheDir, held.directory);
|
|
630
|
+
const { mtimeMs } = await lstat(lockDirectory);
|
|
631
|
+
const stale = staleWindow(staleMs);
|
|
632
|
+
if (Date.now() - mtimeMs >= stale - Math.min(STALE_MARGIN_MS, stale / 3)) return false;
|
|
633
|
+
const tombstone = `${lockDirectory}${TOMBSTONE_INFIX}${randomUUID()}`;
|
|
634
|
+
try {
|
|
635
|
+
await rename(lockDirectory, tombstone);
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (error.code === "ENOENT") return false;
|
|
638
|
+
throw error;
|
|
639
|
+
}
|
|
640
|
+
for (const entry of await readdir(cacheDir).catch(() => [])) if (entry.startsWith(`${held.directory}${TOMBSTONE_INFIX}`)) await rmdir(join(cacheDir, entry)).catch(() => void 0);
|
|
641
|
+
process.emitWarning(`took over the ${held.noun} of a dead holder, ${describeHolder(dead.holder)}: ${dead.reason}`, {
|
|
642
|
+
type: "AgentKnowledgeRecovery",
|
|
643
|
+
code: "AGENT_KNOWLEDGE_LOCK_TAKEOVER"
|
|
644
|
+
});
|
|
645
|
+
return true;
|
|
646
|
+
} finally {
|
|
647
|
+
await release().catch(() => void 0);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
//#endregion
|
|
404
651
|
//#region src/bounded-file-io.ts
|
|
405
652
|
/** Keep filesystem descriptors bounded and drain a failed batch before its owner releases handles. */
|
|
406
653
|
async function mapFileIo(values, operation) {
|
|
@@ -883,244 +1130,6 @@ function fileMatches(file, hash, mode) {
|
|
|
883
1130
|
return (file ? hashBytes(file.bytes) : null) === hash && file?.mode === mode;
|
|
884
1131
|
}
|
|
885
1132
|
//#endregion
|
|
886
|
-
//#region src/lock-holder.ts
|
|
887
|
-
const DEFAULT_STALE_MS = 9e5;
|
|
888
|
-
/** The lease a caller's `staleMs` resolves to: the default, floored at 5 s. */
|
|
889
|
-
function staleWindow(staleMs) {
|
|
890
|
-
return Math.max(5e3, staleMs ?? 9e5);
|
|
891
|
-
}
|
|
892
|
-
/** The store's cross-process lock directory, beside its owner record inside the cache directory. */
|
|
893
|
-
const STORE_LOCK_DIRECTORY = "mutation.lock.durable";
|
|
894
|
-
/** Record of the current cross-process holder: names it in a timeout and proves it dead. */
|
|
895
|
-
const LOCK_OWNER_FILE = "mutation.lock.owner.json";
|
|
896
|
-
/** Held only while one taker breaks a dead holder's lock, so two takers never both break it. */
|
|
897
|
-
const TAKEOVER_GUARD = "mutation.lock.takeover";
|
|
898
|
-
/** A broken lock directory is renamed to `<lock>.dead-<uuid>` beside it, then removed. */
|
|
899
|
-
const TOMBSTONE_INFIX = ".dead-";
|
|
900
|
-
/** How far before `staleMs` a takeover yields to proper-lockfile's own stale removal. */
|
|
901
|
-
const STALE_MARGIN_MS = 6e4;
|
|
902
|
-
function describeHolder(holder, now = Date.now()) {
|
|
903
|
-
const heldS = Math.max(0, Math.round((now - holder.since) / 1e3));
|
|
904
|
-
return `${holder.label} (pid ${holder.pid} on ${holder.host}, holding since ${new Date(holder.since).toISOString()}, ${heldS} s)`;
|
|
905
|
-
}
|
|
906
|
-
let lockfileModule;
|
|
907
|
-
function loadLockfile() {
|
|
908
|
-
lockfileModule ??= import("proper-lockfile");
|
|
909
|
-
return lockfileModule;
|
|
910
|
-
}
|
|
911
|
-
/**
|
|
912
|
-
* The OS start token of a live pid, the way agent-runtime's `readProcessStart` reads it: the same
|
|
913
|
-
* for a process's whole life and different for every later holder of its pid. `undefined` when
|
|
914
|
-
* the host cannot report one. `node:child_process` loads lazily because the package root, which
|
|
915
|
-
* Cloudflare consumers bundle, imports this module.
|
|
916
|
-
*/
|
|
917
|
-
async function readProcessStart(pid) {
|
|
918
|
-
if (!Number.isSafeInteger(pid) || pid <= 0 || process.platform === "win32") return void 0;
|
|
919
|
-
if (process.platform === "linux") {
|
|
920
|
-
let stat;
|
|
921
|
-
try {
|
|
922
|
-
stat = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
923
|
-
} catch {
|
|
924
|
-
return;
|
|
925
|
-
}
|
|
926
|
-
const startTime = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/u)[19];
|
|
927
|
-
return startTime !== void 0 && /^\d+$/u.test(startTime) ? startTime : void 0;
|
|
928
|
-
}
|
|
929
|
-
try {
|
|
930
|
-
const { execFile } = await import("node:child_process");
|
|
931
|
-
return (await new Promise((resolve, reject) => execFile("ps", [
|
|
932
|
-
"-o",
|
|
933
|
-
"lstart=",
|
|
934
|
-
"-p",
|
|
935
|
-
String(pid)
|
|
936
|
-
], (error, out) => error ? reject(error) : resolve(out)))).trim() || void 0;
|
|
937
|
-
} catch {
|
|
938
|
-
return;
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
let ownIdentity;
|
|
942
|
-
function readOwnIdentity() {
|
|
943
|
-
ownIdentity ??= (async () => {
|
|
944
|
-
const [processStart, bootId, pidNamespace] = await Promise.all([
|
|
945
|
-
readProcessStart(process.pid),
|
|
946
|
-
readFile("/proc/sys/kernel/random/boot_id", "utf8").then((text) => text.trim() || void 0, () => void 0),
|
|
947
|
-
readlink("/proc/self/ns/pid").catch(() => void 0)
|
|
948
|
-
]);
|
|
949
|
-
return {
|
|
950
|
-
...processStart ? { processStart } : {},
|
|
951
|
-
...bootId ? { bootId } : {},
|
|
952
|
-
...pidNamespace ? { pidNamespace } : {}
|
|
953
|
-
};
|
|
954
|
-
})();
|
|
955
|
-
return ownIdentity;
|
|
956
|
-
}
|
|
957
|
-
async function readLockDirectoryIdentity(cacheDir) {
|
|
958
|
-
try {
|
|
959
|
-
const entry = await lstat(join(cacheDir, STORE_LOCK_DIRECTORY), { bigint: true });
|
|
960
|
-
if (!entry.isDirectory()) return void 0;
|
|
961
|
-
return {
|
|
962
|
-
dev: entry.dev.toString(),
|
|
963
|
-
ino: entry.ino.toString(),
|
|
964
|
-
birthtimeNs: entry.birthtimeNs.toString()
|
|
965
|
-
};
|
|
966
|
-
} catch {
|
|
967
|
-
return;
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
/**
|
|
971
|
-
* Record who holds the file lock, and which lock directory, inside the store's confinement. Call
|
|
972
|
-
* it right after acquiring. Exported for the confinement tests; src/index.ts does not re-export it.
|
|
973
|
-
*/
|
|
974
|
-
async function recordLockOwner(cacheDir, label) {
|
|
975
|
-
try {
|
|
976
|
-
const [identity, lock] = await Promise.all([readOwnIdentity(), readLockDirectoryIdentity(cacheDir)]);
|
|
977
|
-
const owner = {
|
|
978
|
-
label,
|
|
979
|
-
pid: process.pid,
|
|
980
|
-
host: hostname(),
|
|
981
|
-
since: Date.now(),
|
|
982
|
-
...identity,
|
|
983
|
-
...lock ? { lock } : {}
|
|
984
|
-
};
|
|
985
|
-
await writeJsonDurableWithinRoot(cacheDir, LOCK_OWNER_FILE, owner);
|
|
986
|
-
} catch {}
|
|
987
|
-
}
|
|
988
|
-
/**
|
|
989
|
-
* `cacheDir` is withSafeDirectory's anchored path, so only the owner file itself can be swapped.
|
|
990
|
-
* Anyone who can write the cache directory can plant a FIFO there, so the open adds O_NONBLOCK
|
|
991
|
-
* and fstat refuses it. The shared readRegularFileNoFollow stays blocking: the event store and
|
|
992
|
-
* file transactions read through it, and their descriptor use must not change.
|
|
993
|
-
*
|
|
994
|
-
* The old shape `{ label, pid, host, since }` still parses. A newer field that is missing or
|
|
995
|
-
* malformed is dropped, which leaves the holder named but never provably dead.
|
|
996
|
-
*/
|
|
997
|
-
async function readLockOwner(cacheDir) {
|
|
998
|
-
let handle;
|
|
999
|
-
try {
|
|
1000
|
-
handle = await open(join(cacheDir, LOCK_OWNER_FILE), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
1001
|
-
if (!(await handle.stat()).isFile()) return void 0;
|
|
1002
|
-
const value = JSON.parse(await handle.readFile("utf8"));
|
|
1003
|
-
if (typeof value?.label !== "string" || !Number.isSafeInteger(value.pid) || typeof value.host !== "string" || typeof value.since !== "number" || Number.isNaN(new Date(value.since).getTime())) return;
|
|
1004
|
-
const text = (field) => typeof field === "string" && field.length > 0 ? field : void 0;
|
|
1005
|
-
const lock = value.lock;
|
|
1006
|
-
const decimal = (field) => typeof field === "string" && /^\d+$/u.test(field);
|
|
1007
|
-
const processStart = text(value.processStart);
|
|
1008
|
-
const bootId = text(value.bootId);
|
|
1009
|
-
const pidNamespace = text(value.pidNamespace);
|
|
1010
|
-
return {
|
|
1011
|
-
label: value.label,
|
|
1012
|
-
pid: value.pid,
|
|
1013
|
-
host: value.host,
|
|
1014
|
-
since: value.since,
|
|
1015
|
-
...processStart ? { processStart } : {},
|
|
1016
|
-
...bootId ? { bootId } : {},
|
|
1017
|
-
...pidNamespace ? { pidNamespace } : {},
|
|
1018
|
-
...lock && decimal(lock.dev) && decimal(lock.ino) && decimal(lock.birthtimeNs) ? { lock: {
|
|
1019
|
-
dev: lock.dev,
|
|
1020
|
-
ino: lock.ino,
|
|
1021
|
-
birthtimeNs: lock.birthtimeNs
|
|
1022
|
-
} } : {}
|
|
1023
|
-
};
|
|
1024
|
-
} catch {
|
|
1025
|
-
return;
|
|
1026
|
-
} finally {
|
|
1027
|
-
await handle?.close().catch(() => void 0);
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
/** Only ESRCH proves a pid gone; EPERM means it exists under another user. */
|
|
1031
|
-
function pidIsGone(pid) {
|
|
1032
|
-
try {
|
|
1033
|
-
process.kill(pid, 0);
|
|
1034
|
-
return false;
|
|
1035
|
-
} catch (error) {
|
|
1036
|
-
return error.code === "ESRCH";
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
/**
|
|
1040
|
-
* The holder of the current store lock, when its record proves it dead; otherwise `undefined`,
|
|
1041
|
-
* and the caller keeps the `staleMs` lease. Proof needs a record with every identity field,
|
|
1042
|
-
* written for this very lock directory (dev, ino and a nonzero birthtime), on this host and pid
|
|
1043
|
-
* namespace, and then a rebooted host, a gone pid, or a pid whose start token changed. Anything
|
|
1044
|
-
* else is unknown, so a live holder's lease is never shortened.
|
|
1045
|
-
*/
|
|
1046
|
-
async function holderIsProvablyDead(cacheDir) {
|
|
1047
|
-
const holder = await readLockOwner(cacheDir);
|
|
1048
|
-
if (!holder?.processStart || !holder.lock || holder.pid <= 0) return void 0;
|
|
1049
|
-
const lock = await readLockDirectoryIdentity(cacheDir);
|
|
1050
|
-
if (!lock || lock.birthtimeNs === "0" || lock.dev !== holder.lock.dev || lock.ino !== holder.lock.ino || lock.birthtimeNs !== holder.lock.birthtimeNs || holder.host !== hostname()) return;
|
|
1051
|
-
const own = await readOwnIdentity();
|
|
1052
|
-
if (holder.pidNamespace !== own.pidNamespace) return void 0;
|
|
1053
|
-
if (holder.bootId === void 0 !== (own.bootId === void 0)) return void 0;
|
|
1054
|
-
if (holder.bootId !== own.bootId) return {
|
|
1055
|
-
holder,
|
|
1056
|
-
lock,
|
|
1057
|
-
reason: "host rebooted"
|
|
1058
|
-
};
|
|
1059
|
-
if (pidIsGone(holder.pid)) return {
|
|
1060
|
-
holder,
|
|
1061
|
-
lock,
|
|
1062
|
-
reason: "pid gone"
|
|
1063
|
-
};
|
|
1064
|
-
const start = await readProcessStart(holder.pid);
|
|
1065
|
-
if (start !== void 0 && start !== holder.processStart) return {
|
|
1066
|
-
holder,
|
|
1067
|
-
lock,
|
|
1068
|
-
reason: "pid reused"
|
|
1069
|
-
};
|
|
1070
|
-
}
|
|
1071
|
-
/**
|
|
1072
|
-
* Break the store lock of a provably dead holder, and report whether this call broke it. The
|
|
1073
|
-
* guard is proper-lockfile itself: a taker that finds it held breaks nothing and returns to its
|
|
1074
|
-
* wait, because another taker is already breaking the lock. Its target is the guard path, not the
|
|
1075
|
-
* store root, because proper-lockfile keys its held locks by target and a second lock on the root
|
|
1076
|
-
* would replace this process's store-lock entry and break that lock's heartbeat and release.
|
|
1077
|
-
*
|
|
1078
|
-
* proper-lockfile's own stale removal does not take the guard. Once the lock directory's mtime is
|
|
1079
|
-
* near `staleMs`, another process may remove it as stale and create its own between our re-check
|
|
1080
|
-
* and our rename, so that lock is left to the stale path, which frees it within the margin.
|
|
1081
|
-
*/
|
|
1082
|
-
async function takeOverDeadHolder(cacheDir, staleMs) {
|
|
1083
|
-
if (!await holderIsProvablyDead(cacheDir)) return false;
|
|
1084
|
-
const { lock } = await loadLockfile();
|
|
1085
|
-
const guard = join(cacheDir, TAKEOVER_GUARD);
|
|
1086
|
-
let release;
|
|
1087
|
-
try {
|
|
1088
|
-
release = await lock(guard, {
|
|
1089
|
-
lockfilePath: guard,
|
|
1090
|
-
realpath: false,
|
|
1091
|
-
stale: 3e4,
|
|
1092
|
-
retries: 0,
|
|
1093
|
-
onCompromised: () => void 0
|
|
1094
|
-
});
|
|
1095
|
-
} catch (error) {
|
|
1096
|
-
if (error.code === "ELOCKED") return false;
|
|
1097
|
-
throw error;
|
|
1098
|
-
}
|
|
1099
|
-
try {
|
|
1100
|
-
const dead = await holderIsProvablyDead(cacheDir);
|
|
1101
|
-
if (!dead) return false;
|
|
1102
|
-
const lockDirectory = join(cacheDir, STORE_LOCK_DIRECTORY);
|
|
1103
|
-
const { mtimeMs } = await lstat(lockDirectory);
|
|
1104
|
-
const stale = staleWindow(staleMs);
|
|
1105
|
-
if (Date.now() - mtimeMs >= stale - Math.min(STALE_MARGIN_MS, stale / 3)) return false;
|
|
1106
|
-
const tombstone = `${lockDirectory}${TOMBSTONE_INFIX}${randomUUID()}`;
|
|
1107
|
-
try {
|
|
1108
|
-
await rename(lockDirectory, tombstone);
|
|
1109
|
-
} catch (error) {
|
|
1110
|
-
if (error.code === "ENOENT") return false;
|
|
1111
|
-
throw error;
|
|
1112
|
-
}
|
|
1113
|
-
for (const entry of await readdir(cacheDir).catch(() => [])) if (entry.startsWith(`mutation.lock.durable${TOMBSTONE_INFIX}`)) await rmdir(join(cacheDir, entry)).catch(() => void 0);
|
|
1114
|
-
process.emitWarning(`took over the knowledge store lock of a dead holder, ${describeHolder(dead.holder)}: ${dead.reason}`, {
|
|
1115
|
-
type: "AgentKnowledgeRecovery",
|
|
1116
|
-
code: "AGENT_KNOWLEDGE_LOCK_TAKEOVER"
|
|
1117
|
-
});
|
|
1118
|
-
return true;
|
|
1119
|
-
} finally {
|
|
1120
|
-
await release().catch(() => void 0);
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
//#endregion
|
|
1124
1133
|
//#region src/mutation-lock.ts
|
|
1125
1134
|
const DEFAULT_READ_WAIT_MS = 25;
|
|
1126
1135
|
const MAX_POLL_MS = 250;
|
|
@@ -1320,7 +1329,7 @@ async function withKnowledgeMutation(root, mutate, options = {}) {
|
|
|
1320
1329
|
* fixed number of attempts delays a write instead of failing it.
|
|
1321
1330
|
*/
|
|
1322
1331
|
async function acquireStoreLock(root, cacheDir, options) {
|
|
1323
|
-
const lockfilePath = join(cacheDir,
|
|
1332
|
+
const lockfilePath = join(cacheDir, STORE_LOCK.directory);
|
|
1324
1333
|
const deadline = options.started + WRITER_WAIT_MS;
|
|
1325
1334
|
let pause = 10;
|
|
1326
1335
|
for (;;) try {
|
|
@@ -1631,7 +1640,7 @@ async function waitForActiveMutationEpoch(root, epoch, options, pauseMs) {
|
|
|
1631
1640
|
async function healAbandonedMutationEpoch(root, expectedEpoch, options) {
|
|
1632
1641
|
await withSafeDirectory(root, ".agent-knowledge", false, async (cacheDir) => {
|
|
1633
1642
|
const acquire = () => acquireDurableFileLock(root, {
|
|
1634
|
-
lockfilePath: join(cacheDir,
|
|
1643
|
+
lockfilePath: join(cacheDir, STORE_LOCK.directory),
|
|
1635
1644
|
staleMs: options.staleMs,
|
|
1636
1645
|
retries: 0
|
|
1637
1646
|
}).catch((error) => {
|
|
@@ -1660,7 +1669,7 @@ async function hasActiveMutationLock(root, options) {
|
|
|
1660
1669
|
try {
|
|
1661
1670
|
const { check } = await loadLockfile();
|
|
1662
1671
|
return await withSafeDirectory(root, ".agent-knowledge", false, async (cacheDir) => await check(root, {
|
|
1663
|
-
lockfilePath: join(cacheDir,
|
|
1672
|
+
lockfilePath: join(cacheDir, STORE_LOCK.directory),
|
|
1664
1673
|
realpath: false,
|
|
1665
1674
|
stale: staleWindow(options.staleMs)
|
|
1666
1675
|
}) && !await holderIsProvablyDead(cacheDir));
|
|
@@ -5249,6 +5258,6 @@ function explainKnowledgeTarget(index, target) {
|
|
|
5249
5258
|
};
|
|
5250
5259
|
}
|
|
5251
5260
|
//#endregion
|
|
5252
|
-
export { loadSourceRegistry as $,
|
|
5261
|
+
export { loadSourceRegistry as $, writeFileDurableWithinRoot as $n, KNOWLEDGE_EVENT_TYPES as $t, validateKnowledgePage as A, finishKnowledgeFileTransaction as An, isScaffoldPath as At, CHECKABLE_RUNG_THRESHOLD as B, canonicalRelativeWithinRoot as Bn, KnowledgeEventSchema as Bt, immutablePageDigest as C, runInKnowledgeMutationScope as Cn, resolveKnowledgeCitation as Ct, normalizeVisibilityArtifact as D, applyKnowledgeFileTransaction as Dn, SCAFFOLD_PAGE_BASENAMES as Dt, nonEmpty as E, withKnowledgeRead as En, resolveRunScopedCitations as Et, detectNearDuplicatePages as F, recordLockOwner as Fn, WIKILINK_REGEX as Ft, gradeClaims as G, readRegularFileWithinRoot as Gn, KnowledgePageSchema as Gt, UncheckableClaimError as H, isMissingFile as Hn, KnowledgeGraphNodeSchema as Ht, normalizePageText as I, takeOverDeadHolder as In, extractWikilinks as It, verifyGradeableEvidence as J, renameDurable as Jn, ResearchClaimLedgerSchema as Jt, gradeFor as K, relativeWithinRoot as Kn, KnowledgeRelationSchema as Kt, prepareKnowledgeValidation as L, DEFAULT_PAGES_DIRECTORY as Ln, normalizeLinkTarget as Lt, verifyKnowledgeVisibilitySnapshot as M, prepareKnowledgeFileTransaction as Mn, layoutFor as Mt, visibilityIdentity as N, rollbackKnowledgeFileTransaction as Nn, loadKnowledgePages as Nt, normalizeVisibilityRef as O, assertKnowledgeMutationPath as On, initKnowledgeBase as Ot, visibilityIndexOf as P, RUN_LEASE as Pn, writeJson as Pt, addSourceText as Q, writeFileDurable as Qn, SourceRecordSchema as Qt, validateKnowledgeIndex as R, normalizePagesDirectory as Rn, DeepQuestionSchema as Rt, encodeKnowledgeVisibilitySnapshot as S, recoverPendingKnowledgeMutation as Sn, parseKnowledgeCitationReference as St, knowledgeVisibilityArtifactRef as T, withKnowledgeMutationLabel as Tn, resolveRunScopedCitation as Tt, assertGradeableEvidence as U, listRegularFilesWithinRoot as Un, KnowledgeIndexSchema as Ut, DEADLINE_EXIT_CODE as V, isKernelAnchoredPath as Vn, KnowledgeGraphEdgeSchema as Vt, claimCheckKey as W, readRegularFileNoFollow as Wn, KnowledgePageInvalidationSchema as Wt, writeKnowledgeIndex as X, withSafeDescendant as Xn, ResearchSourceVersionSchema as Xt, buildKnowledgeIndex as Y, syncDirectory as Yn, ResearchClaimRecordSchema as Yt, addSourcePath as Z, withSafeDirectory as Zn, SourceAnchorSchema as Zt, KNOWLEDGE_USE_RECEIPT_SCHEMA_VERSION as _, parseFrontmatter as _n, assertKnowledgeCitationsResolved as _t, applyKnowledgeWriteBlocksFile as a, claimEvidenceId as an, KB_CLAIM_LEDGER_DIR as at, decodeKnowledgeVisibilitySnapshot as b, isKnowledgeMutationHeld as bn, auditKnowledgeCitations as bt, KnowledgeDuplicateIntakeError as c, deepQuestionId as cn, KB_STORE_DIR as ct, createFileRunLineageAuthority as d, materializeRegisteredClaimEvidence as dn, buildKnowledgeGraph as dt, ClaimLedgerGoalConflictError as en, writeJsonDurable as er, snapshotSourceTextInput as et, createRunScopedStores as f, mergeClaimLedgers as fn, knowledgePageRelations as ft, KNOWLEDGE_RECEIPT_DIGEST_ALGORITHM as g, formatFrontmatter as gn, assertKnowledgeCitationAudit as gt, originatedPages as h, researchSourceVersionKey as hn, assertCurrentRunCitationsResolved as ht, applyKnowledgeWriteBlocks as i, assertTrackedClaimIntegrity as in, FileSystemKbStore as it, validateOrigin as j, knowledgeFileTransactionPlanHash as jn, knowledgePageFromMarkdown as jt, snapshotImmutableVisibility as k, commitKnowledgeFileMutations as kn, isKnowledgePagePath as kt, assertKnowledgeWriteIntake as l, emptyClaimLedger as ln, MemoryKbStore as lt, loadKnowledgeToolChain as m, normalizeClaimText as mn, KnowledgeCitationResolutionError as mt, inspectKnowledgeIndex as n, assertResearchClaimEvidenceIntegrity as nn, mediaTypeFor$1 as nr, writeSourceRegistry as nt, isSafeKnowledgePath as o, claimId as on, KB_EVENTS_PATH as ot, isKnowledgeToolChain as p, mergeTrackedClaims as pn, KnowledgeCitationAuditError as pt, verdictFor as q, removeDurable as qn, ResearchClaimEvidenceSchema as qt, stringMetadata as r, assertResearchClaimLedgerIntegrity as rn, textSourceAdapter as rr, ClaimLedgerMigrationRequiredError as rt, parseKnowledgeWriteBlocks as s, claimSourceHost as sn, KB_INDEX_PATH as st, explainKnowledgeTarget as t, assertDeepQuestionIntegrity as tn, writeJsonDurableWithinRoot as tr, sourceRegistryPath as tt, RUN_LINEAGE_BASENAME as u, linkClaimContradictions as un, assertClaimLedgerId as ut, assertSchemaVersion as v, acquireDurableFileLock as vn, assertRunScopedCitationsResolved as vt, knowledgePageDigest as w, withKnowledgeMutation as wn, resolveKnowledgeCitations as wt, digest as x, measureKnowledgeMutation as xn, formatKnowledgeCitationReference as xt, createKnowledgeVisibilitySnapshot as y, inspectPendingKnowledgeMutation as yn, auditCurrentRunCitations as yt, lintKnowledgeIndex as z, canonicalPathsEqual as zn, KnowledgeBaseCandidateSchema as zt };
|
|
5253
5262
|
|
|
5254
|
-
//# sourceMappingURL=inspect-
|
|
5263
|
+
//# sourceMappingURL=inspect-CTpCsUB2.js.map
|