@tangle-network/agent-knowledge 22.0.6 → 22.0.8
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 +8 -0
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/{inspect-CZrwj4Ry.js → inspect-Ct0VmbIZ.js} +299 -78
- package/dist/inspect-Ct0VmbIZ.js.map +1 -0
- package/docs/architecture.md +7 -2
- package/package.json +3 -3
- package/dist/inspect-CZrwj4Ry.js.map +0 -1
|
@@ -5,7 +5,7 @@ import { contentHash } from "@tangle-network/agent-eval";
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
|
|
8
|
-
import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm,
|
|
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
10
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
11
|
import { hostname } from "node:os";
|
|
@@ -883,8 +883,245 @@ function fileMatches(file, hash, mode) {
|
|
|
883
883
|
return (file ? hashBytes(file.bytes) : null) === hash && file?.mode === mode;
|
|
884
884
|
}
|
|
885
885
|
//#endregion
|
|
886
|
-
//#region src/
|
|
886
|
+
//#region src/lock-holder.ts
|
|
887
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
|
+
//#region src/mutation-lock.ts
|
|
888
1125
|
const DEFAULT_READ_WAIT_MS = 25;
|
|
889
1126
|
const MAX_POLL_MS = 250;
|
|
890
1127
|
function sleep(ms) {
|
|
@@ -907,18 +1144,18 @@ const WRITER_WAIT_MS = DEFAULT_STALE_MS;
|
|
|
907
1144
|
* A read changes nothing, so failing one loses no work, which is why it waits less than a writer.
|
|
908
1145
|
*/
|
|
909
1146
|
const READER_WAIT_MS = 12e4;
|
|
1147
|
+
/** Purpose prefix of a proposal write; its purpose hashes the exact proposal. */
|
|
1148
|
+
const KNOWLEDGE_PROPOSAL_PURPOSE_PREFIX = "knowledge-proposal:";
|
|
910
1149
|
/**
|
|
911
|
-
*
|
|
912
|
-
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
1150
|
+
* A pending transaction with no recovery owner, found by a caller that holds the exclusive lock
|
|
1151
|
+
* and is not resuming that purpose. The transaction belongs to the lock hold that opened it, and
|
|
1152
|
+
* holding the lock now proves that hold ended: released on an error or exit, expired, or broken
|
|
1153
|
+
* because its holder was provably dead. Its caller never saw success, so rolling it back leaves
|
|
1154
|
+
* the store as that caller last saw it, and an identical retry still writes cleanly.
|
|
915
1155
|
*/
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
return pending.purpose.startsWith("knowledge-proposal:") && !pending.recoveryOwner;
|
|
1156
|
+
function isAbandonedTransaction(pending, resumingPurpose) {
|
|
1157
|
+
return !pending.recoveryOwner && pending.purpose !== resumingPurpose;
|
|
919
1158
|
}
|
|
920
|
-
/** Diagnostic record of the current cross-process holder, read only to name it in a timeout. */
|
|
921
|
-
const LOCK_OWNER_FILE = "mutation.lock.owner.json";
|
|
922
1159
|
const holderLabels = new AsyncLocalStorage();
|
|
923
1160
|
/**
|
|
924
1161
|
* Name the store mutations `body` performs, so a writer or reader that times out behind them can
|
|
@@ -927,18 +1164,9 @@ const holderLabels = new AsyncLocalStorage();
|
|
|
927
1164
|
function withKnowledgeMutationLabel(label, body) {
|
|
928
1165
|
return holderLabels.run(label, body);
|
|
929
1166
|
}
|
|
930
|
-
function describeHolder(holder, now = Date.now()) {
|
|
931
|
-
const heldS = Math.max(0, Math.round((now - holder.since) / 1e3));
|
|
932
|
-
return `${holder.label} (pid ${holder.pid} on ${holder.host}, holding since ${new Date(holder.since).toISOString()}, ${heldS} s)`;
|
|
933
|
-
}
|
|
934
1167
|
function seconds(ms) {
|
|
935
1168
|
return `${Math.round(ms / 100) / 10} s`;
|
|
936
1169
|
}
|
|
937
|
-
let lockfileModule;
|
|
938
|
-
function loadLockfile() {
|
|
939
|
-
lockfileModule ??= import("proper-lockfile");
|
|
940
|
-
return lockfileModule;
|
|
941
|
-
}
|
|
942
1170
|
const activeRoots = new AsyncLocalStorage();
|
|
943
1171
|
const activeReadRoots = new AsyncLocalStorage();
|
|
944
1172
|
const gates = /* @__PURE__ */ new Map();
|
|
@@ -1092,7 +1320,7 @@ async function withKnowledgeMutation(root, mutate, options = {}) {
|
|
|
1092
1320
|
* fixed number of attempts delays a write instead of failing it.
|
|
1093
1321
|
*/
|
|
1094
1322
|
async function acquireStoreLock(root, cacheDir, options) {
|
|
1095
|
-
const lockfilePath = join(cacheDir,
|
|
1323
|
+
const lockfilePath = join(cacheDir, STORE_LOCK_DIRECTORY);
|
|
1096
1324
|
const deadline = options.started + WRITER_WAIT_MS;
|
|
1097
1325
|
let pause = 10;
|
|
1098
1326
|
for (;;) try {
|
|
@@ -1102,6 +1330,7 @@ async function acquireStoreLock(root, cacheDir, options) {
|
|
|
1102
1330
|
retries: options.retries ?? 0
|
|
1103
1331
|
});
|
|
1104
1332
|
} catch (error) {
|
|
1333
|
+
if (isLocked(error) && await takeOverDeadHolder(cacheDir, options.staleMs)) continue;
|
|
1105
1334
|
const remaining = deadline - performance.now();
|
|
1106
1335
|
if (options.retries !== void 0 || error.code !== "ELOCKED" || remaining <= 0) throw await namedLockError(error, root, cacheDir, performance.now() - options.started);
|
|
1107
1336
|
await sleep(Math.min(remaining, pause * (.5 + Math.random())));
|
|
@@ -1110,29 +1339,12 @@ async function acquireStoreLock(root, cacheDir, options) {
|
|
|
1110
1339
|
}
|
|
1111
1340
|
/** A contended file lock, restated with the holder its owner record names. */
|
|
1112
1341
|
async function namedLockError(error, root, cacheDir, waitedMs) {
|
|
1113
|
-
if (error
|
|
1342
|
+
if (!isLocked(error)) return error;
|
|
1114
1343
|
const owner = await readLockOwner(cacheDir);
|
|
1115
1344
|
return Object.assign(new Error(`knowledge store is locked by another process: ${root}; waited ${seconds(waitedMs)} for ${owner ? describeHolder(owner) : "a holder that recorded no owner"}`, { cause: error }), { code: "ELOCKED" });
|
|
1116
1345
|
}
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
const owner = {
|
|
1120
|
-
label,
|
|
1121
|
-
pid: process.pid,
|
|
1122
|
-
host: hostname(),
|
|
1123
|
-
since: Date.now()
|
|
1124
|
-
};
|
|
1125
|
-
try {
|
|
1126
|
-
await writeFile(join(cacheDir, LOCK_OWNER_FILE), `${JSON.stringify(owner)}\n`);
|
|
1127
|
-
} catch {}
|
|
1128
|
-
}
|
|
1129
|
-
async function readLockOwner(cacheDir) {
|
|
1130
|
-
try {
|
|
1131
|
-
const value = JSON.parse(await readFile(join(cacheDir, LOCK_OWNER_FILE), "utf8"));
|
|
1132
|
-
return typeof value.label === "string" && Number.isSafeInteger(value.pid) && typeof value.host === "string" && Number.isFinite(value.since) ? value : void 0;
|
|
1133
|
-
} catch {
|
|
1134
|
-
return;
|
|
1135
|
-
}
|
|
1346
|
+
function isLocked(error) {
|
|
1347
|
+
return error?.code === "ELOCKED";
|
|
1136
1348
|
}
|
|
1137
1349
|
function noRetries(retries) {
|
|
1138
1350
|
return retries === 0 || typeof retries === "object" && !Array.isArray(retries) && retries.retries === 0;
|
|
@@ -1149,6 +1361,8 @@ function noRetries(retries) {
|
|
|
1149
1361
|
* an externally held lock carries the same guarantee and not a weaker one. Entering a scope for a
|
|
1150
1362
|
* lock the caller does not actually hold is the one way to defeat the store's single-writer rule;
|
|
1151
1363
|
* the hold is required, rather than assumed, so that is a statement the caller has to make.
|
|
1364
|
+
* Holding it also authorizes rolling back a pending transaction with no recovery owner, as every
|
|
1365
|
+
* holder of the store lock does.
|
|
1152
1366
|
*/
|
|
1153
1367
|
async function runInKnowledgeMutationScope(root, hold, body, options = {}) {
|
|
1154
1368
|
const resolvedRoot = resolve(root);
|
|
@@ -1183,7 +1397,8 @@ async function runOwnedMutation(resolvedRoot, cacheDir, hold, options, mutate) {
|
|
|
1183
1397
|
transactionRoot: mutationLock.transactionRoot
|
|
1184
1398
|
});
|
|
1185
1399
|
const pending = pendingState?.transaction ?? null;
|
|
1186
|
-
const
|
|
1400
|
+
const abandoned = pending !== null && isAbandonedTransaction(pending, options.resumeTransaction?.purpose);
|
|
1401
|
+
const resume = pending && !abandoned ? assertKnowledgeTransactionResume(pending, options.resumeTransaction) : void 0;
|
|
1187
1402
|
const locks = new Map(activeRoots.getStore());
|
|
1188
1403
|
locks.set(resolvedRoot, scope);
|
|
1189
1404
|
return await activeRoots.run(locks, async () => {
|
|
@@ -1198,8 +1413,8 @@ async function runOwnedMutation(resolvedRoot, cacheDir, hold, options, mutate) {
|
|
|
1198
1413
|
if (!stillPending) await finishMutationEpoch(cacheDir, epoch);
|
|
1199
1414
|
};
|
|
1200
1415
|
try {
|
|
1201
|
-
if (pending)
|
|
1202
|
-
|
|
1416
|
+
if (pending && abandoned) await rollBackAbandonedTransaction(resolvedRoot, mutationLock.transactionRoot, pending, mutationLock.assertOwned);
|
|
1417
|
+
else if (pending && resume) {
|
|
1203
1418
|
await recoverKnowledgeFileTransaction({
|
|
1204
1419
|
root: resolvedRoot,
|
|
1205
1420
|
transactionRoot: mutationLock.transactionRoot,
|
|
@@ -1251,20 +1466,32 @@ async function inspectPendingKnowledgeMutation(root) {
|
|
|
1251
1466
|
paths: transaction.entries.map((entry) => entry.path)
|
|
1252
1467
|
};
|
|
1253
1468
|
}
|
|
1469
|
+
/** Called once the abandoned case is excluded, so a mismatched purpose names a recovery owner. */
|
|
1254
1470
|
function assertKnowledgeTransactionResume(pending, resume) {
|
|
1255
|
-
if (!resume || pending.purpose !== resume.purpose)
|
|
1256
|
-
|
|
1257
|
-
purpose: pending.purpose,
|
|
1258
|
-
direction: "rollback"
|
|
1259
|
-
};
|
|
1260
|
-
throw new Error(`knowledge transaction '${pending.purpose}' requires its owner to resume`);
|
|
1261
|
-
}
|
|
1262
|
-
if (pending.recoveryOwner !== resume.recoveryOwner) {
|
|
1263
|
-
if (pending.recoveryOwner) throw new Error(`knowledge transaction '${pending.purpose}' must be resumed by '${pending.recoveryOwner}'`);
|
|
1264
|
-
throw new Error(`knowledge transaction '${pending.purpose}' has no recovery owner`);
|
|
1265
|
-
}
|
|
1471
|
+
if (!resume || pending.purpose !== resume.purpose || pending.recoveryOwner && pending.recoveryOwner !== resume.recoveryOwner) throw ownerResumeRefusal(pending);
|
|
1472
|
+
if (pending.recoveryOwner !== resume.recoveryOwner) throw new Error(`knowledge transaction '${pending.purpose}' has no recovery owner`);
|
|
1266
1473
|
return resume;
|
|
1267
1474
|
}
|
|
1475
|
+
/** The refusal every reader and writer meets while an owned transaction is pending. */
|
|
1476
|
+
function ownerResumeRefusal(pending) {
|
|
1477
|
+
const owner = pending.recoveryOwner ?? "its recovery owner";
|
|
1478
|
+
const transition = pending.purpose.startsWith("knowledge-promotion:") ? "promoteKnowledgeCandidate" : pending.purpose.startsWith("knowledge-restore:") ? "restoreKnowledgeCandidateBaseline" : void 0;
|
|
1479
|
+
const resumeCall = transition ? `re-run ${transition} with the same candidate${owner.startsWith("knowledge-improvement-activation:") ? " and the same activation" : ""}` : `re-run the call that owns '${owner}' with the same purpose`;
|
|
1480
|
+
return /* @__PURE__ */ new Error(`knowledge transaction '${pending.purpose}' requires its owner to resume: it must be resumed by '${owner}' (transaction ${pending.transactionId}, created ${pending.createdAt}); ${resumeCall}`);
|
|
1481
|
+
}
|
|
1482
|
+
async function rollBackAbandonedTransaction(root, transactionRoot, pending, assertOwned) {
|
|
1483
|
+
await recoverKnowledgeFileTransaction({
|
|
1484
|
+
root,
|
|
1485
|
+
transactionRoot,
|
|
1486
|
+
expectedPurpose: pending.purpose,
|
|
1487
|
+
direction: "rollback",
|
|
1488
|
+
assertOwned
|
|
1489
|
+
});
|
|
1490
|
+
process.emitWarning(`rolled back abandoned knowledge transaction ${pending.transactionId} ('${pending.purpose}', created ${pending.createdAt}): its writer ended without success and named no recovery owner`, {
|
|
1491
|
+
type: "AgentKnowledgeRecovery",
|
|
1492
|
+
code: "AGENT_KNOWLEDGE_TRANSACTION_ROLLBACK"
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1268
1495
|
async function recoverPendingKnowledgeMutation(root, options) {
|
|
1269
1496
|
const pending = await inspectPendingKnowledgeMutation(root);
|
|
1270
1497
|
if (!pending) throw new Error("knowledge base has no pending mutation");
|
|
@@ -1327,7 +1554,7 @@ async function describeWriter(root) {
|
|
|
1327
1554
|
return owner ? describeHolder(owner) : "a writer that recorded no owner";
|
|
1328
1555
|
}
|
|
1329
1556
|
async function acquireDurableFileLock(target, options) {
|
|
1330
|
-
const stale =
|
|
1557
|
+
const stale = staleWindow(options.staleMs);
|
|
1331
1558
|
const update = Math.max(1e3, Math.floor(stale / 3));
|
|
1332
1559
|
let compromised;
|
|
1333
1560
|
let released = false;
|
|
@@ -1403,17 +1630,17 @@ async function waitForActiveMutationEpoch(root, epoch, options, pauseMs) {
|
|
|
1403
1630
|
}
|
|
1404
1631
|
async function healAbandonedMutationEpoch(root, expectedEpoch, options) {
|
|
1405
1632
|
await withSafeDirectory(root, ".agent-knowledge", false, async (cacheDir) => {
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
});
|
|
1413
|
-
} catch (error) {
|
|
1414
|
-
if (error.code === "ELOCKED") return;
|
|
1633
|
+
const acquire = () => acquireDurableFileLock(root, {
|
|
1634
|
+
lockfilePath: join(cacheDir, STORE_LOCK_DIRECTORY),
|
|
1635
|
+
staleMs: options.staleMs,
|
|
1636
|
+
retries: 0
|
|
1637
|
+
}).catch((error) => {
|
|
1638
|
+
if (isLocked(error)) return void 0;
|
|
1415
1639
|
throw error;
|
|
1416
|
-
}
|
|
1640
|
+
});
|
|
1641
|
+
const acquired = await acquire() ?? (await takeOverDeadHolder(cacheDir, options.staleMs) ? await acquire() : void 0);
|
|
1642
|
+
if (!acquired) return;
|
|
1643
|
+
await recordLockOwner(cacheDir, "heal of an abandoned mutation epoch");
|
|
1417
1644
|
try {
|
|
1418
1645
|
const currentEpoch = await readMutationEpochFromCache(cacheDir);
|
|
1419
1646
|
if (currentEpoch !== expectedEpoch || !isOdd(currentEpoch)) return;
|
|
@@ -1421,14 +1648,8 @@ async function healAbandonedMutationEpoch(root, expectedEpoch, options) {
|
|
|
1421
1648
|
root,
|
|
1422
1649
|
transactionRoot: join(cacheDir, "file-transactions")
|
|
1423
1650
|
});
|
|
1424
|
-
if (pending &&
|
|
1425
|
-
|
|
1426
|
-
transactionRoot: join(cacheDir, "file-transactions"),
|
|
1427
|
-
expectedPurpose: pending.transaction.purpose,
|
|
1428
|
-
direction: "rollback",
|
|
1429
|
-
assertOwned: () => acquired.assertOwned()
|
|
1430
|
-
});
|
|
1431
|
-
else if (pending) throw new Error(`knowledge transaction '${pending.transaction.purpose}' requires its owner to resume`);
|
|
1651
|
+
if (pending && isAbandonedTransaction(pending.transaction)) await rollBackAbandonedTransaction(root, join(cacheDir, "file-transactions"), pending.transaction, () => acquired.assertOwned());
|
|
1652
|
+
else if (pending) throw ownerResumeRefusal(pending.transaction);
|
|
1432
1653
|
await finishMutationEpoch(cacheDir, currentEpoch);
|
|
1433
1654
|
} finally {
|
|
1434
1655
|
await acquired.release();
|
|
@@ -1438,11 +1659,11 @@ async function healAbandonedMutationEpoch(root, expectedEpoch, options) {
|
|
|
1438
1659
|
async function hasActiveMutationLock(root, options) {
|
|
1439
1660
|
try {
|
|
1440
1661
|
const { check } = await loadLockfile();
|
|
1441
|
-
return await withSafeDirectory(root, ".agent-knowledge", false, (cacheDir) => check(root, {
|
|
1662
|
+
return await withSafeDirectory(root, ".agent-knowledge", false, async (cacheDir) => await check(root, {
|
|
1442
1663
|
lockfilePath: join(cacheDir, "mutation.lock.durable"),
|
|
1443
1664
|
realpath: false,
|
|
1444
|
-
stale:
|
|
1445
|
-
}));
|
|
1665
|
+
stale: staleWindow(options.staleMs)
|
|
1666
|
+
}) && !await holderIsProvablyDead(cacheDir));
|
|
1446
1667
|
} catch (error) {
|
|
1447
1668
|
if (isMissingFile(error)) return false;
|
|
1448
1669
|
throw error;
|
|
@@ -5030,4 +5251,4 @@ function explainKnowledgeTarget(index, target) {
|
|
|
5030
5251
|
//#endregion
|
|
5031
5252
|
export { loadSourceRegistry as $, mediaTypeFor$1 as $n, KNOWLEDGE_EVENT_TYPES as $t, validateKnowledgePage as A, finishKnowledgeFileTransaction as An, isScaffoldPath as At, CHECKABLE_RUNG_THRESHOLD as B, listRegularFilesWithinRoot 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, normalizePagesDirectory as Fn, WIKILINK_REGEX as Ft, gradeClaims as G, renameDurable as Gn, KnowledgePageSchema as Gt, UncheckableClaimError as H, readRegularFileWithinRoot as Hn, KnowledgeGraphNodeSchema as Ht, normalizePageText as I, canonicalPathsEqual as In, extractWikilinks as It, verifyGradeableEvidence as J, withSafeDirectory as Jn, ResearchClaimLedgerSchema as Jt, gradeFor as K, syncDirectory as Kn, KnowledgeRelationSchema as Kt, prepareKnowledgeValidation as L, canonicalRelativeWithinRoot 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, DEFAULT_PAGES_DIRECTORY as Pn, writeJson as Pt, addSourceText as Q, writeJsonDurableWithinRoot as Qn, SourceRecordSchema as Qt, validateKnowledgeIndex as R, isKernelAnchoredPath 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, relativeWithinRoot as Un, KnowledgeIndexSchema as Ut, DEADLINE_EXIT_CODE as V, readRegularFileNoFollow as Vn, KnowledgeGraphEdgeSchema as Vt, claimCheckKey as W, removeDurable as Wn, KnowledgePageInvalidationSchema as Wt, writeKnowledgeIndex as X, writeFileDurableWithinRoot as Xn, ResearchSourceVersionSchema as Xt, buildKnowledgeIndex as Y, writeFileDurable as Yn, ResearchClaimRecordSchema as Yt, addSourcePath as Z, writeJsonDurable 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, textSourceAdapter 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, 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, withSafeDescendant as qn, ResearchClaimEvidenceSchema as qt, stringMetadata as r, assertResearchClaimLedgerIntegrity as rn, ClaimLedgerMigrationRequiredError as rt, parseKnowledgeWriteBlocks as s, claimSourceHost as sn, KB_INDEX_PATH as st, explainKnowledgeTarget as t, assertDeepQuestionIntegrity as tn, 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, isMissingFile as zn, KnowledgeBaseCandidateSchema as zt };
|
|
5032
5253
|
|
|
5033
|
-
//# sourceMappingURL=inspect-
|
|
5254
|
+
//# sourceMappingURL=inspect-Ct0VmbIZ.js.map
|