claude-mem-lite 6.25.2 → 6.25.3
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook-update.mjs +39 -6
- package/install.mjs +211 -33
- package/lib/binding-probe.mjs +9 -1
- package/lib/native-binding-hint.mjs +3 -1
- package/lib/proc-lock.mjs +136 -13
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scripts/binding-probe-cli.mjs +5 -2
- package/scripts/hook-launcher.mjs +1 -3
- package/scripts/launch.mjs +13 -3
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.25.
|
|
12
|
+
"version": "6.25.3",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"homepage": "https://github.com/sdsrss/claude-mem-lite",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.25.
|
|
3
|
+
"version": "6.25.3",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/hook-update.mjs
CHANGED
|
@@ -43,7 +43,11 @@ import {
|
|
|
43
43
|
// because checkForUpdate is silent on network failure the plugin then reports
|
|
44
44
|
// itself permanently up to date. Same tunnel the OpenRouter call site uses.
|
|
45
45
|
import { httpConnectProxyFor, getViaConnectProxy, redactProxyUrl } from './lib/proxy-fetch.mjs';
|
|
46
|
-
import
|
|
46
|
+
// A namespace, not a named import: a swap renames files one at a time, so for a moment this file can
|
|
47
|
+
// sit over the previous release's lib/proc-lock.mjs, and a named import of an export that release
|
|
48
|
+
// lacks is a SyntaxError that stops this module loading — and with it recoverInterruptedSwaps, which
|
|
49
|
+
// finishes that very swap (pre-ship delta review P2-1). takeLock is newer than v6.25.2: detected.
|
|
50
|
+
import * as procLock from './lib/proc-lock.mjs';
|
|
47
51
|
import { atomicWriteFileSync } from './lib/atomic-write.mjs';
|
|
48
52
|
import { verifyReleaseFiles, verifyManifestSignature } from './lib/release-digest.mjs';
|
|
49
53
|
import { detectInstallShape } from './lib/install-shape.mjs';
|
|
@@ -262,7 +266,7 @@ function pluginOnlyInstall() {
|
|
|
262
266
|
return Boolean(shape && !shape.managed && shape.activePluginVersion);
|
|
263
267
|
}
|
|
264
268
|
|
|
265
|
-
function isPluginMode() {
|
|
269
|
+
export function isPluginMode() {
|
|
266
270
|
return Boolean(process.env.CLAUDE_PLUGIN_ROOT) || pluginOnlyInstall();
|
|
267
271
|
}
|
|
268
272
|
|
|
@@ -964,6 +968,30 @@ const BACKUP_SEQ_RE = /^\.update-backup-s(\d+)-/;
|
|
|
964
968
|
const swapSeqOf = (name) => Number(BACKUP_SEQ_RE.exec(name)?.[1] ?? 0);
|
|
965
969
|
const nextSwapSeq = (names) => Math.max(0, ...names.map(swapSeqOf)) + 1;
|
|
966
970
|
|
|
971
|
+
/** Unresolved backup dir names in the order recovery undoes them: newest first, by seq, then name.
|
|
972
|
+
* cleanup --dry-run walks the same order to say what recovery would leave (D#307 F3). */
|
|
973
|
+
const unresolvedSwapOrder = (names) =>
|
|
974
|
+
[...names].sort((a, b) => swapSeqOf(b) - swapSeqOf(a) || b.localeCompare(a));
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Which unresolved backup dirs recovery would leave in place because a newer one's journal cannot be
|
|
978
|
+
* read: recovery walks them newest first and stops there (recoverInterruptedSwaps). cleanup --dry-run
|
|
979
|
+
* reports from this instead of judging each dir alone (D#307 F3). It cannot foresee a file that
|
|
980
|
+
* fails to go back, which also stops the walk.
|
|
981
|
+
* @param {{name: string, readable: boolean}[]} swaps
|
|
982
|
+
* @returns {Map<string, string>} name → the newer unreadable dir that blocks it
|
|
983
|
+
*/
|
|
984
|
+
export function swapsBlockedByUnreadable(swaps) {
|
|
985
|
+
const readable = new Map(swaps.map((s) => [s.name, s.readable]));
|
|
986
|
+
const blockedBy = new Map();
|
|
987
|
+
let newestUnreadable = null;
|
|
988
|
+
for (const name of unresolvedSwapOrder([...readable.keys()])) {
|
|
989
|
+
if (newestUnreadable) blockedBy.set(name, newestUnreadable);
|
|
990
|
+
else if (!readable.get(name)) newestUnreadable = name;
|
|
991
|
+
}
|
|
992
|
+
return blockedBy;
|
|
993
|
+
}
|
|
994
|
+
|
|
967
995
|
// The `.update-backup-*` dirs in targetDir that carry no resolved marker.
|
|
968
996
|
function unresolvedBackups(targetDir) {
|
|
969
997
|
try {
|
|
@@ -1058,8 +1086,7 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
|
|
|
1058
1086
|
// they are undone like a stack: newest first by the seq in the name, and none older while a newer
|
|
1059
1087
|
// one is unfinished or cannot be read. An older journal replayed first puts back a file that a
|
|
1060
1088
|
// newer journal, which installed that path, then deletes; with nothing left to restore it from.
|
|
1061
|
-
|
|
1062
|
-
for (const name of pending) {
|
|
1089
|
+
for (const name of unresolvedSwapOrder(pending)) {
|
|
1063
1090
|
const dir = join(targetDir, name);
|
|
1064
1091
|
// No journal: the swap is over and its journal is gone (or it was killed before its first
|
|
1065
1092
|
// rename), so there is nothing to replay and the dir is residue. A journal that is there but
|
|
@@ -1241,12 +1268,18 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1241
1268
|
// holding the lock means an install is already in flight — skip rather than
|
|
1242
1269
|
// race. Shared path with install.mjs so direct install + repair + auto-update
|
|
1243
1270
|
// are mutually exclusive.
|
|
1244
|
-
const
|
|
1271
|
+
const lockPath = join(STATE_DIR, 'runtime', 'install.lock'); // runtime-dir:stays-put — install lock serialises real installers
|
|
1272
|
+
const { release, error: lockError } = procLock.takeLock
|
|
1273
|
+
? procLock.takeLock(lockPath)
|
|
1274
|
+
: { release: procLock.acquireLock(lockPath) };
|
|
1245
1275
|
if (!release) {
|
|
1276
|
+
// doctor is the face that names a lock that cannot be taken (D#307); this log is debug-only.
|
|
1246
1277
|
debugLog(
|
|
1247
1278
|
'DEBUG',
|
|
1248
1279
|
'hook-update',
|
|
1249
|
-
|
|
1280
|
+
lockError
|
|
1281
|
+
? `installExtractedRelease: install.lock cannot be taken — ${lockError.dir} (${lockError.code}) — skipping`
|
|
1282
|
+
: 'installExtractedRelease: another install/update is in progress — skipping',
|
|
1250
1283
|
);
|
|
1251
1284
|
return false;
|
|
1252
1285
|
}
|
package/install.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
lstatSync,
|
|
19
19
|
constants as fsConstants,
|
|
20
20
|
} from 'fs';
|
|
21
|
-
import { join, resolve, dirname, basename, sep } from 'path';
|
|
21
|
+
import { join, resolve, dirname, basename, sep, relative, isAbsolute } from 'path';
|
|
22
22
|
import { homedir, tmpdir } from 'os';
|
|
23
23
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
24
24
|
import { createRequire } from 'node:module';
|
|
@@ -93,11 +93,25 @@ import {
|
|
|
93
93
|
ensureBetterSqlite3Working,
|
|
94
94
|
nativeBindingRepairHint,
|
|
95
95
|
isNativeBindingError,
|
|
96
|
+
probeBindingInFreshProcess,
|
|
97
|
+
flattenBindingError,
|
|
96
98
|
} from './lib/binding-probe.mjs';
|
|
97
99
|
import { detectInstallShape, probeRuntimeRoots, hasAnyManagedCode } from './lib/install-shape.mjs';
|
|
98
100
|
import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
|
|
99
101
|
import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
|
|
100
|
-
|
|
102
|
+
// A namespace, not named imports: a swap renames files one at a time, so for a moment this file can
|
|
103
|
+
// sit over the previous release's lib/proc-lock.mjs, and a named import of an export that release
|
|
104
|
+
// lacks is a SyntaxError that stops install.mjs loading — and repair and cleanup are what finish that
|
|
105
|
+
// state (pre-ship delta review P2-1). Exports newer than v6.25.2 are detected.
|
|
106
|
+
import * as procLock from './lib/proc-lock.mjs';
|
|
107
|
+
const { lockHeld } = procLock;
|
|
108
|
+
const lockDirBlocked = procLock.lockDirBlocked ?? (() => null);
|
|
109
|
+
const takeLock =
|
|
110
|
+
procLock.takeLock ??
|
|
111
|
+
((p) => {
|
|
112
|
+
const release = procLock.acquireLock(p);
|
|
113
|
+
return release ? { release } : {};
|
|
114
|
+
});
|
|
101
115
|
import { atomicWriteFileSync, atomicCopyFileSync } from './lib/atomic-write.mjs';
|
|
102
116
|
import { shellWord } from './cli-path.mjs';
|
|
103
117
|
import { dirAccessError, walOpenBlocked, dataDirRemedy, WAL_BLOCKED_WHY } from './lib/wal-open-blocked.mjs';
|
|
@@ -1873,6 +1887,7 @@ async function status() {
|
|
|
1873
1887
|
const unwritable = dataDirDenied
|
|
1874
1888
|
? ` — ${MEM_DATA_DIR} can be read but not written (${dataDirDenied}). Fix: ${dataDirAccessRemedy(dataDirDenied)}`
|
|
1875
1889
|
: '';
|
|
1890
|
+
let statusProbe;
|
|
1876
1891
|
if (dataDirUnreadable) {
|
|
1877
1892
|
push(
|
|
1878
1893
|
'fail',
|
|
@@ -1880,6 +1895,17 @@ async function status() {
|
|
|
1880
1895
|
`Database: ${MEM_DATA_DIR} is not accessible (${dataDirUnreadable}) — Fix: ${dataDirAccessRemedy(dataDirUnreadable)}`,
|
|
1881
1896
|
{ exists: null, error: dataDirUnreadable },
|
|
1882
1897
|
);
|
|
1898
|
+
} else if (existsSync(DB_PATH) && !(statusProbe = probeBindingInFreshProcess(PROJECT_DIR)).ok) {
|
|
1899
|
+
// Out of process first, as doctor does: the open below loads the addon IN this process, and a
|
|
1900
|
+
// truncated prebuild raises SIGBUS in dlopen rather than throwing, so status died with no
|
|
1901
|
+
// output (D#306). The human line says why it did not look; the machine face keeps the loader's
|
|
1902
|
+
// own error (review P3-10).
|
|
1903
|
+
push(
|
|
1904
|
+
dataDirDenied ? 'fail' : 'warn',
|
|
1905
|
+
'database',
|
|
1906
|
+
`Database: exists, but not checked — ${BINDING_MISSING_WHY}${unwritable}`,
|
|
1907
|
+
{ exists: true, error: flattenBindingError(statusProbe.error) },
|
|
1908
|
+
);
|
|
1883
1909
|
} else if (existsSync(DB_PATH)) {
|
|
1884
1910
|
try {
|
|
1885
1911
|
const Database = (await import('better-sqlite3')).default;
|
|
@@ -2173,6 +2199,11 @@ async function doctor() {
|
|
|
2173
2199
|
// process.
|
|
2174
2200
|
const rootProbes = probeRuntimeRoots(shape.runtimeRoots);
|
|
2175
2201
|
const brokenRoots = rootProbes.filter((r) => !r.ok);
|
|
2202
|
+
// Database and DB stats below open the store IN this process, through the better-sqlite3 the
|
|
2203
|
+
// running CLI's tree resolves. When the out-of-process probe of that tree has already failed,
|
|
2204
|
+
// loading it here is not just pointless: a truncated prebuild raises SIGBUS in dlopen, which
|
|
2205
|
+
// killed doctor (exit 135) before it printed the rest (D#306). They say "not checked" instead.
|
|
2206
|
+
const bindingBrokenHere = rootProbes.some((r) => !r.ok && sameDir(r.root, PROJECT_DIR));
|
|
2176
2207
|
if (rootProbes.length === 0 && codeDirDenied) {
|
|
2177
2208
|
// The code home is locked, so it may own a binding nobody could look at (D#284 review P3-4).
|
|
2178
2209
|
notCheckedDenied('better-sqlite3', INSTALL_DIR);
|
|
@@ -2604,6 +2635,8 @@ async function doctor() {
|
|
|
2604
2635
|
// Database
|
|
2605
2636
|
if (dataDirUnreadable) {
|
|
2606
2637
|
notCheckedDenied('Database');
|
|
2638
|
+
} else if (existsSync(DB_PATH) && bindingBrokenHere) {
|
|
2639
|
+
dwarn(`Database: not checked — ${BINDING_MISSING_WHY}`);
|
|
2607
2640
|
} else if (existsSync(DB_PATH)) {
|
|
2608
2641
|
try {
|
|
2609
2642
|
const Database = (await import('better-sqlite3')).default;
|
|
@@ -3028,8 +3061,34 @@ async function doctor() {
|
|
|
3028
3061
|
// install.lock is held, cleanup skips update residue (a running update's own staging and backup
|
|
3029
3062
|
// dirs among it), so doctor does not count it either, and says it did not look (D#297). The holder
|
|
3030
3063
|
// may be any installer: an update, install, repair, a binding rebuild. It asks without taking it.
|
|
3064
|
+
// A runtime dir install.lock cannot be created in (a sudo run left it root-owned, a read-only data
|
|
3065
|
+
// dir): every update entry skips on it as if a peer held the lock, silently, so this is the only
|
|
3066
|
+
// face that can say why updates stopped (D#307). Counted here unless the Data directory line above
|
|
3067
|
+
// already counts the directory it sits in.
|
|
3068
|
+
const installLockBlocked = lockDirBlocked(INSTALL_LOCK_PATH);
|
|
3069
|
+
if (installLockBlocked) {
|
|
3070
|
+
const line = `Install lock: ${lockBlockedWhy(installLockBlocked)} — every update, install, repair and binding rebuild skips until it can be taken`;
|
|
3071
|
+
// The same directory the Data directory line counts (or one above it), or the same read-only
|
|
3072
|
+
// mount: one fault, one ✗. A runtime dir inside a data dir that both need fixing is two.
|
|
3073
|
+
const relToData = relative(MEM_DATA_DIR, installLockBlocked.dir);
|
|
3074
|
+
const sameFault =
|
|
3075
|
+
dataDirDenied &&
|
|
3076
|
+
(relToData === '' ||
|
|
3077
|
+
relToData.startsWith('..') ||
|
|
3078
|
+
isAbsolute(relToData) ||
|
|
3079
|
+
(dataDirDenied === 'EROFS' && installLockBlocked.code === 'EROFS'));
|
|
3080
|
+
if (sameFault) dwarn(line);
|
|
3081
|
+
else {
|
|
3082
|
+
fail(line);
|
|
3083
|
+
issues++;
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3031
3086
|
try {
|
|
3032
|
-
|
|
3087
|
+
// Held and blocked are separate answers. A lock can be both: a sudo-run installer still running
|
|
3088
|
+
// in the root-owned runtime dir it made holds it there (pre-ship review). Held, the residue may
|
|
3089
|
+
// be that installer's own and is not counted; blocked and not held, an unfinished swap is a real
|
|
3090
|
+
// one and is counted (D#289), and every repair it names waits on the lock line's fix.
|
|
3091
|
+
const lockHeldNow = lockHeld(INSTALL_LOCK_PATH);
|
|
3033
3092
|
const { stale, inFlight, unfinishedSwaps, unreadableJournals, notChecked } = scanStaleTempFiles({
|
|
3034
3093
|
codeDir: INSTALL_DIR,
|
|
3035
3094
|
runtimeDir: MEM_RUNTIME_DIR,
|
|
@@ -3037,14 +3096,16 @@ async function doctor() {
|
|
|
3037
3096
|
installLockHeld: lockHeldNow,
|
|
3038
3097
|
});
|
|
3039
3098
|
const installer = `node ${shellWord(join(PROJECT_DIR, 'install.mjs'))}`;
|
|
3099
|
+
const onceLock =
|
|
3100
|
+
installLockBlocked && !lockHeldNow ? ' once install.lock can be taken (see Install lock)' : '';
|
|
3040
3101
|
if (unfinishedSwaps > 0) {
|
|
3041
3102
|
dwarn(
|
|
3042
|
-
`Unfinished update: ${unfinishedSwaps} backup dir(s) in ${INSTALL_DIR} still hold a swap journal — an update was interrupted, or could not put back every file it moved aside (run: ${installer} cleanup — it finishes them newest first, and stops at one it cannot read or put back)`,
|
|
3103
|
+
`Unfinished update: ${unfinishedSwaps} backup dir(s) in ${INSTALL_DIR} still hold a swap journal — an update was interrupted, or could not put back every file it moved aside (run: ${installer} cleanup${onceLock} — it finishes them newest first, and stops at one it cannot read or put back)`,
|
|
3043
3104
|
);
|
|
3044
3105
|
}
|
|
3045
3106
|
if (unreadableJournals > 0) {
|
|
3046
3107
|
dwarn(
|
|
3047
|
-
`Unfinished update: ${unreadableJournals} backup dir(s) in ${INSTALL_DIR} whose journal cannot be read, so nothing can finish that update; they hold the files it moved aside (run: ${installer} repair — it reinstalls over them; cleanup then removes them)`,
|
|
3108
|
+
`Unfinished update: ${unreadableJournals} backup dir(s) in ${INSTALL_DIR} whose journal cannot be read, so nothing can finish that update; they hold the files it moved aside (run: ${installer} repair${onceLock} — it reinstalls over them; cleanup then removes them)`,
|
|
3048
3109
|
);
|
|
3049
3110
|
}
|
|
3050
3111
|
const skipped = lockHeldNow
|
|
@@ -3054,11 +3115,13 @@ async function doctor() {
|
|
|
3054
3115
|
const where = notChecked.map((n) => `${n.dir} is not accessible (${n.code})`).join('; ');
|
|
3055
3116
|
dwarn(
|
|
3056
3117
|
`Stale temp files: not checked — ${where}` +
|
|
3057
|
-
(stale > 0 ? `; ${stale} found elsewhere (run: ${installer} cleanup)` : '') +
|
|
3118
|
+
(stale > 0 ? `; ${stale} found elsewhere (run: ${installer} cleanup${onceLock})` : '') +
|
|
3058
3119
|
(skipped ? `; ${skipped}` : ''),
|
|
3059
3120
|
);
|
|
3060
3121
|
} else if (stale > 0) {
|
|
3061
|
-
dwarn(
|
|
3122
|
+
dwarn(
|
|
3123
|
+
`Stale temp files: ${stale} found (run: ${installer} cleanup${onceLock})${skipped ? `; ${skipped}` : ''}`,
|
|
3124
|
+
);
|
|
3062
3125
|
} else if (skipped) {
|
|
3063
3126
|
dwarn(`Stale temp files: ${skipped}`);
|
|
3064
3127
|
} else {
|
|
@@ -3081,7 +3144,9 @@ async function doctor() {
|
|
|
3081
3144
|
}
|
|
3082
3145
|
|
|
3083
3146
|
// DB stats
|
|
3084
|
-
if (existsSync(DB_PATH)) {
|
|
3147
|
+
if (existsSync(DB_PATH) && bindingBrokenHere) {
|
|
3148
|
+
dwarn(`DB stats: not checked — ${BINDING_MISSING_WHY}`);
|
|
3149
|
+
} else if (existsSync(DB_PATH)) {
|
|
3085
3150
|
try {
|
|
3086
3151
|
const dbSize = statSync(DB_PATH).size;
|
|
3087
3152
|
const sizeMB = (dbSize / 1024 / 1024).toFixed(1);
|
|
@@ -3504,10 +3569,23 @@ async function cleanup() {
|
|
|
3504
3569
|
let finished = 0;
|
|
3505
3570
|
// --dry-run writes nothing, so it asks whether the lock is held instead of taking it: taking it
|
|
3506
3571
|
// created the data dir, its runtime dir and the lock on a machine with nothing installed (D#297).
|
|
3507
|
-
|
|
3508
|
-
|
|
3572
|
+
// A lock that cannot be taken at all is not one a peer holds (D#304): both runs name its directory.
|
|
3573
|
+
// The real run takes the answer from its own failed create (any errno); the dry run, which must not
|
|
3574
|
+
// create anything, predicts it from the directory (lockDirBlocked).
|
|
3575
|
+
let updateLock = null;
|
|
3576
|
+
let lockBlocked;
|
|
3577
|
+
if (dryRun) {
|
|
3578
|
+
lockBlocked = lockDirBlocked(INSTALL_LOCK_PATH);
|
|
3579
|
+
if (!lockBlocked && !lockHeld(INSTALL_LOCK_PATH)) updateLock = () => {};
|
|
3580
|
+
} else {
|
|
3581
|
+
const taken = takeLock(INSTALL_LOCK_PATH);
|
|
3582
|
+
updateLock = taken.release || null;
|
|
3583
|
+
lockBlocked = taken.error || null;
|
|
3584
|
+
}
|
|
3509
3585
|
if (!updateLock) {
|
|
3510
|
-
warn(
|
|
3586
|
+
warn(
|
|
3587
|
+
`Update residue skipped: ${lockBlocked ? lockBlockedWhy(lockBlocked) : 'install in progress (install.lock held)'}`,
|
|
3588
|
+
);
|
|
3511
3589
|
} else {
|
|
3512
3590
|
try {
|
|
3513
3591
|
let found = [];
|
|
@@ -3517,8 +3595,29 @@ async function cleanup() {
|
|
|
3517
3595
|
warn(`Update residue: could not list ${INSTALL_DIR} (${e.code || e.message})`);
|
|
3518
3596
|
}
|
|
3519
3597
|
if (dryRun) {
|
|
3598
|
+
// Recovery undoes unfinished swaps like a stack, newest first, and stops at the first whose
|
|
3599
|
+
// journal cannot be read, leaving it and every older one (D#293). Judged one dir at a time,
|
|
3600
|
+
// the dry run promised to finish an older swap the real run leaves (D#307 F3), so it walks
|
|
3601
|
+
// them in recovery's own order, taken from hook-update rather than restated here.
|
|
3602
|
+
const swaps = found
|
|
3603
|
+
.filter((r) => r.kind === 'unfinished-swap' || r.kind === 'unreadable-journal')
|
|
3604
|
+
.map((r) => ({ name: r.f, readable: r.kind === 'unfinished-swap' }));
|
|
3605
|
+
let blockedBy = new Map();
|
|
3606
|
+
let cannotLoad = null;
|
|
3607
|
+
if (swaps.some((s) => s.readable)) {
|
|
3608
|
+
try {
|
|
3609
|
+
const { swapsBlockedByUnreadable } = await import('./hook-update.mjs');
|
|
3610
|
+
blockedBy = swapsBlockedByUnreadable(swaps);
|
|
3611
|
+
} catch (e) {
|
|
3612
|
+
cannotLoad = e.message;
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3520
3615
|
for (const { f, kind } of found) {
|
|
3521
|
-
if (kind === 'unfinished-swap') {
|
|
3616
|
+
if (kind === 'unfinished-swap' && cannotLoad) {
|
|
3617
|
+
warn(`Would leave in place: ${f} (the updater that finishes it cannot load: ${cannotLoad})`);
|
|
3618
|
+
} else if (kind === 'unfinished-swap' && blockedBy.has(f)) {
|
|
3619
|
+
warn(`Would leave in place: ${f} (a newer one, ${blockedBy.get(f)}, cannot be finished first)`);
|
|
3620
|
+
} else if (kind === 'unfinished-swap') {
|
|
3522
3621
|
ok(`Would finish an interrupted update: ${f} (puts back the files its journal lists)`);
|
|
3523
3622
|
finished++;
|
|
3524
3623
|
} else if (kind === 'unreadable-journal') {
|
|
@@ -3547,21 +3646,29 @@ async function cleanup() {
|
|
|
3547
3646
|
}
|
|
3548
3647
|
// A relocated data dir that an updater older than v2.90.0 swapped into: its residue is stale,
|
|
3549
3648
|
// whatever it holds, and is never replayed (lib/doctor-stale-temp.mjs, legacyDir).
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3649
|
+
let legacyEntries = [];
|
|
3650
|
+
if (!sameDir(INSTALL_DIR, MEM_DATA_DIR)) {
|
|
3651
|
+
try {
|
|
3652
|
+
legacyEntries = readdirSync(MEM_DATA_DIR);
|
|
3653
|
+
} catch (e) {
|
|
3654
|
+
// Absent is nothing to clean; one it can enter but not list threw out of cleanup (review P3-6).
|
|
3655
|
+
if (e.code !== 'ENOENT')
|
|
3656
|
+
warn(`Update residue: could not list ${MEM_DATA_DIR} (${e.code || e.message})`);
|
|
3657
|
+
}
|
|
3658
|
+
}
|
|
3659
|
+
for (const f of legacyEntries) {
|
|
3660
|
+
if (!isUpdateResidue(f)) continue;
|
|
3661
|
+
if (dryRun) {
|
|
3662
|
+
ok(`Would remove: ${join(MEM_DATA_DIR, f)}`);
|
|
3663
|
+
removed++;
|
|
3664
|
+
continue;
|
|
3665
|
+
}
|
|
3666
|
+
try {
|
|
3667
|
+
rmSync(join(MEM_DATA_DIR, f), { recursive: true, force: true });
|
|
3668
|
+
ok(`Removed: ${join(MEM_DATA_DIR, f)}`);
|
|
3669
|
+
removed++;
|
|
3670
|
+
} catch (e) {
|
|
3671
|
+
warn(`Failed to remove ${join(MEM_DATA_DIR, f)}: ${e.message}`);
|
|
3565
3672
|
}
|
|
3566
3673
|
}
|
|
3567
3674
|
} finally {
|
|
@@ -3582,10 +3689,18 @@ async function cleanup() {
|
|
|
3582
3689
|
// fixture sweep below, whose comment already states the principle: a MANUAL cleanup is
|
|
3583
3690
|
// the conservative one.
|
|
3584
3691
|
const runtimeDir = MEM_RUNTIME_DIR;
|
|
3585
|
-
|
|
3692
|
+
let runtimeEntries = [];
|
|
3693
|
+
try {
|
|
3694
|
+
runtimeEntries = readdirSync(runtimeDir);
|
|
3695
|
+
} catch (e) {
|
|
3696
|
+
// Absent is nothing to clean. A runtime dir that cannot be listed (a sudo run left it 0700 and
|
|
3697
|
+
// root-owned) threw out of cleanup uncaught, dry run included (D#304).
|
|
3698
|
+
if (e.code !== 'ENOENT') warn(`Runtime scratch: could not list ${runtimeDir} (${e.code || e.message})`);
|
|
3699
|
+
}
|
|
3700
|
+
if (runtimeEntries.length > 0) {
|
|
3586
3701
|
const now = Date.now();
|
|
3587
3702
|
let inFlight = 0;
|
|
3588
|
-
for (const f of
|
|
3703
|
+
for (const f of runtimeEntries) {
|
|
3589
3704
|
if (isEpisodeResidue(f)) {
|
|
3590
3705
|
// The gate itself lives in lib/doctor-stale-temp.mjs, so doctor's count and this
|
|
3591
3706
|
// deletion cannot disagree about which files are in flight (D#53).
|
|
@@ -3625,6 +3740,10 @@ async function cleanup() {
|
|
|
3625
3740
|
if (removed > 0) done.push(`${removed} stale file(s) ${verb}`);
|
|
3626
3741
|
if (finished > 0)
|
|
3627
3742
|
done.push(`${finished} interrupted update(s) ${dryRun ? 'would be finished' : 'finished'}`);
|
|
3743
|
+
// Skipped update residue is not "nothing found" (review P3-4). Held, a peer is working on it; a
|
|
3744
|
+
// lock that cannot be taken is a failure no wait clears, so the exit says so, as install's does.
|
|
3745
|
+
if (!updateLock) done.push('update residue not checked (see above)');
|
|
3746
|
+
if (lockBlocked) process.exitCode = 1;
|
|
3628
3747
|
console.log(`\n ${done.length === 0 ? 'No stale files found.' : `${done.join('; ')}.`}\n`);
|
|
3629
3748
|
}
|
|
3630
3749
|
|
|
@@ -3635,7 +3754,8 @@ async function manualUpdate() {
|
|
|
3635
3754
|
console.log('\nclaude-mem-lite self-update\n');
|
|
3636
3755
|
|
|
3637
3756
|
// Force check by importing hook-update (bypasses throttle for manual use)
|
|
3638
|
-
const { checkForUpdate, getCurrentVersion, updateCheckDisabledReason } =
|
|
3757
|
+
const { checkForUpdate, getCurrentVersion, updateCheckDisabledReason, isPluginMode } =
|
|
3758
|
+
await import('./hook-update.mjs');
|
|
3639
3759
|
// checkForUpdate returns null without looking in these two cases, and the branch at the
|
|
3640
3760
|
// bottom would then report "Already up to date" for a check that never ran. (D#251)
|
|
3641
3761
|
const disabled = updateCheckDisabledReason();
|
|
@@ -3651,6 +3771,16 @@ async function manualUpdate() {
|
|
|
3651
3771
|
console.log('');
|
|
3652
3772
|
return;
|
|
3653
3773
|
}
|
|
3774
|
+
// An update installs under install.lock, and hook-update reads a lock it cannot take as "an install
|
|
3775
|
+
// is in progress", which this printed as "install failed" (review P3-5).
|
|
3776
|
+
// Plugin mode only checks, it never installs, so the lock is not its business (delta review P3-3).
|
|
3777
|
+
const lockBlocked = isPluginMode?.() ? null : lockDirBlocked(INSTALL_LOCK_PATH);
|
|
3778
|
+
if (lockBlocked) {
|
|
3779
|
+
warn(`Not checked: ${lockBlockedWhy(lockBlocked)}`);
|
|
3780
|
+
process.exitCode = 1;
|
|
3781
|
+
console.log('');
|
|
3782
|
+
return;
|
|
3783
|
+
}
|
|
3654
3784
|
log('Checking for updates...');
|
|
3655
3785
|
const result = await checkForUpdate({ force: true, allowInstall: true, reportFailure: true });
|
|
3656
3786
|
|
|
@@ -3687,6 +3817,15 @@ async function manualUpdate() {
|
|
|
3687
3817
|
// buggy on disk.
|
|
3688
3818
|
async function repair() {
|
|
3689
3819
|
console.log('\nclaude-mem-lite repair — re-syncing from the latest SIGNED GitHub release\n');
|
|
3820
|
+
// The install this ends in takes install.lock. One that cannot be taken here fails that install
|
|
3821
|
+
// after a whole release was downloaded, and the manual fallback below fails the same way, so say
|
|
3822
|
+
// it first and stop (review P3-5). A peer holding it is left to the install's own message.
|
|
3823
|
+
const lockBlocked = lockDirBlocked(INSTALL_LOCK_PATH);
|
|
3824
|
+
if (lockBlocked) {
|
|
3825
|
+
fail(`Repair not started: ${lockBlockedWhy(lockBlocked)}`);
|
|
3826
|
+
process.exitCode = 1;
|
|
3827
|
+
return;
|
|
3828
|
+
}
|
|
3690
3829
|
const stagingDir = mkdtempSync(join(tmpdir(), 'claude-mem-lite-repair-'));
|
|
3691
3830
|
try {
|
|
3692
3831
|
// Resolve the latest RELEASE (tag) and cryptographically VERIFY it before running any
|
|
@@ -3929,12 +4068,16 @@ function bindingHostDir() {
|
|
|
3929
4068
|
// two concurrent rebuilds can clobber the .node mid-compile. A live peer → report
|
|
3930
4069
|
// and exit 0 (it is doing this very work), never race it.
|
|
3931
4070
|
async function rebuildBinding() {
|
|
3932
|
-
const release
|
|
4071
|
+
const { release, error: blocked } = takeLock(INSTALL_LOCK_PATH);
|
|
3933
4072
|
if (!release) {
|
|
3934
4073
|
// NOT exit 0: skipping is not healing. Callers key their state on the exit
|
|
3935
4074
|
// code — a false success would let the launcher drop its cooldown and the
|
|
3936
4075
|
// CLI re-exec into the same broken binding.
|
|
3937
|
-
console.error(
|
|
4076
|
+
console.error(
|
|
4077
|
+
blocked
|
|
4078
|
+
? `[install] ${lockBlockedWhy(blocked)} — the binding was not rebuilt.`
|
|
4079
|
+
: '[install] Another install/repair is in progress — it owns the rebuild; skipping.',
|
|
4080
|
+
);
|
|
3938
4081
|
process.exitCode = 1;
|
|
3939
4082
|
return;
|
|
3940
4083
|
}
|
|
@@ -4011,6 +4154,35 @@ function sameDir(a, b) {
|
|
|
4011
4154
|
// The fix for a data dir this user cannot use, chosen by the error code (lib/wal-open-blocked.mjs).
|
|
4012
4155
|
export const dataDirAccessRemedy = (code) => dataDirRemedy(code, MEM_DATA_DIR);
|
|
4013
4156
|
|
|
4157
|
+
// install.lock serialises every installer (install, repair, update, binding rebuild, cleanup's
|
|
4158
|
+
// update residue). One that cannot be created at all is not one a peer holds, and each face says
|
|
4159
|
+
// which (D#304, D#307): waiting does not clear it, the fix below does.
|
|
4160
|
+
const INSTALL_LOCK_PATH = join(MEM_DATA_DIR, 'runtime', 'install.lock'); // runtime-dir:stays-put — install lock serialises real installers
|
|
4161
|
+
const lockBlockedWhy = ({ dir, code }) =>
|
|
4162
|
+
`install.lock cannot be taken — ${dir} cannot be written (${code}). Fix: ${lockDirRemedy(code, dir)}`;
|
|
4163
|
+
|
|
4164
|
+
// The fix for the directory lockDirBlocked / takeLock name. One ABOVE the data dir (it does not exist
|
|
4165
|
+
// yet and cannot be created there, or cannot be reached) is not this program's to chmod or chown: on
|
|
4166
|
+
// a CLAUDE_MEM_DIR under /opt that advice would hand /opt to the user (review P3-3).
|
|
4167
|
+
function lockDirRemedy(code, dir) {
|
|
4168
|
+
const rel = relative(MEM_DATA_DIR, dir);
|
|
4169
|
+
// Creating the data dir fixes only a parent that can be entered but not written; one that cannot be
|
|
4170
|
+
// entered (the data dir may already be the user's), or a link to nowhere, leaves CLAUDE_MEM_DIR
|
|
4171
|
+
// (delta review P3-1).
|
|
4172
|
+
const above = rel.startsWith('..') || isAbsolute(rel);
|
|
4173
|
+
if (above && (code === 'ENOENT' || dirAccessError(dir, fsConstants.X_OK)))
|
|
4174
|
+
return `${shellWord(dir)} cannot be entered or does not exist — set CLAUDE_MEM_DIR to a directory your user can write`;
|
|
4175
|
+
if (above)
|
|
4176
|
+
return `make ${shellWord(MEM_DATA_DIR)} a directory your user owns (e.g. sudo mkdir -p ${shellWord(MEM_DATA_DIR)} && sudo chown "$USER" ${shellWord(MEM_DATA_DIR)}), or set CLAUDE_MEM_DIR to one`;
|
|
4177
|
+
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') return dataDirRemedy(code, dir);
|
|
4178
|
+
if (code === 'ENOSPC' || code === 'EDQUOT')
|
|
4179
|
+
return `free space on the file system holding ${shellWord(dir)}`;
|
|
4180
|
+
if (code === 'ENOTDIR') return `${shellWord(dir)} is a file, not a directory — move it aside`;
|
|
4181
|
+
if (code === 'ENOENT')
|
|
4182
|
+
return `${shellWord(dir)} is a link to a directory that does not exist — create its target, or remove the link`;
|
|
4183
|
+
return `check that your user can create files in ${shellWord(dir)}`;
|
|
4184
|
+
}
|
|
4185
|
+
|
|
4014
4186
|
// This process cannot load better-sqlite3: none installed where it runs (a checkout, or a plugin
|
|
4015
4187
|
// cache before its first launch installed node_modules), or one that will not load (a stale ABI, a
|
|
4016
4188
|
// damaged prebuild). The better-sqlite3 line names and counts that; a line that would open the DB
|
|
@@ -4028,8 +4200,14 @@ async function runLockedInstall() {
|
|
|
4028
4200
|
process.exitCode = 1;
|
|
4029
4201
|
return;
|
|
4030
4202
|
}
|
|
4031
|
-
const release
|
|
4203
|
+
const { release, error: blocked } = takeLock(INSTALL_LOCK_PATH);
|
|
4032
4204
|
if (!release) {
|
|
4205
|
+
if (blocked) {
|
|
4206
|
+
// Not a peer: nothing will ever release it, and exit 0 told the caller it installed.
|
|
4207
|
+
console.error(`[install] ${lockBlockedWhy(blocked)} — nothing was done.`);
|
|
4208
|
+
process.exitCode = 1;
|
|
4209
|
+
return;
|
|
4210
|
+
}
|
|
4033
4211
|
console.log('[install] Another install/repair is in progress — skipping to avoid a torn write.');
|
|
4034
4212
|
return;
|
|
4035
4213
|
}
|
package/lib/binding-probe.mjs
CHANGED
|
@@ -76,10 +76,16 @@ export function nativeBindingRepairHint(root) {
|
|
|
76
76
|
// Set on a re-exec'd child so one failed heal cannot fork-bomb the CLI.
|
|
77
77
|
export const BINDING_HEAL_GUARD_ENV = 'CLAUDE_MEM_BINDING_HEALED';
|
|
78
78
|
|
|
79
|
-
// The native-binding fault family, in the
|
|
79
|
+
// The native-binding fault family, in the shapes it actually reaches callers:
|
|
80
80
|
// • ERR_DLOPEN_FAILED — Node's code for a failed dlopen (ABI mismatch)
|
|
81
81
|
// • NODE_MODULE_VERSION N vs M — the ABI text itself (some throws carry no code)
|
|
82
82
|
// • Could not locate the bindings file — build/Release missing or never compiled
|
|
83
|
+
// (better-sqlite3 ≤12, through the `bindings` package)
|
|
84
|
+
// • Cannot find module '…better_sqlite3.node' — the same fault on 13, which dropped
|
|
85
|
+
// `bindings`: with no prebuild for the platform and no source build, lib/binding.js
|
|
86
|
+
// requires build/Release/better_sqlite3.node and Node throws a plain MODULE_NOT_FOUND
|
|
87
|
+
// (D#306). Every first load on a platform 13 ships no prebuild for. Anchored on the
|
|
88
|
+
// addon's file name: a missing PACKAGE needs an install, not a rebuild.
|
|
83
89
|
// • Module did not self-register — the .node was REPLACED under a process that
|
|
84
90
|
// already dlopen'd the old one; only a fresh process recovers (hence the
|
|
85
91
|
// re-exec in healAndReexec, not an in-process retry)
|
|
@@ -88,6 +94,8 @@ export const BINDING_HEAL_GUARD_ENV = 'CLAUDE_MEM_BINDING_HEALED';
|
|
|
88
94
|
const NATIVE_BINDING_PATTERNS = [
|
|
89
95
|
/NODE_MODULE_VERSION/,
|
|
90
96
|
/Could not locate the bindings file/i,
|
|
97
|
+
// A quote may sit inside the path; the quoted path must end the line (Node's own shape).
|
|
98
|
+
/Cannot find module '[^\n]*[\\/]better_sqlite3\.node'(?:\r?\n|$)/,
|
|
91
99
|
/did not self-register/i,
|
|
92
100
|
/invalid ELF header/i,
|
|
93
101
|
];
|
|
@@ -135,9 +135,11 @@ export function formatHookError(err, event, { now = Date.now(), runtimeDir } = {
|
|
|
135
135
|
!nativeBindingHintDue(runtimeDir, now, NATIVE_BINDING_HINT_COOLDOWN_MS, errKey(err.message))
|
|
136
136
|
)
|
|
137
137
|
return null;
|
|
138
|
+
// No guessed cause: better-sqlite3 13's addons are N-API and survive a Node upgrade, and on 13 the
|
|
139
|
+
// addon can also never have been there — no prebuild for the platform (D#306).
|
|
138
140
|
return (
|
|
139
141
|
`[claude-mem-lite] [${ts}] [WARN] ${event}: native DB binding can't load ` +
|
|
140
|
-
|
|
142
|
+
`— auto-heals at the next session start, or run now: ${CLI_REBUILD_BINDING}`
|
|
141
143
|
);
|
|
142
144
|
}
|
|
143
145
|
return `[claude-mem-lite] [${ts}] [ERROR] ${event}: ${err && err.message}`;
|
package/lib/proc-lock.mjs
CHANGED
|
@@ -16,7 +16,18 @@
|
|
|
16
16
|
// already doing the write). Release unlinks the file. Crash-safe: a crashed holder's
|
|
17
17
|
// pid is gone, so the next session reclaims its lock at once.
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
writeFileSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
unlinkSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
renameSync,
|
|
25
|
+
linkSync,
|
|
26
|
+
lstatSync,
|
|
27
|
+
statSync,
|
|
28
|
+
accessSync,
|
|
29
|
+
constants as fsConstants,
|
|
30
|
+
} from 'node:fs';
|
|
20
31
|
import { dirname } from 'node:path';
|
|
21
32
|
import { workerDirGone } from './worker-data-dir.mjs';
|
|
22
33
|
|
|
@@ -75,13 +86,21 @@ function isStale(lockPath, staleMs, now) {
|
|
|
75
86
|
* temp carries a `.lock` suffix so hook.mjs's sweeper reclaims it if we die in between.
|
|
76
87
|
* Filesystems without hard links (some network / FAT mounts) fall back to the old form —
|
|
77
88
|
* they keep the narrow window, which is still better than never acquiring a lock at all.
|
|
89
|
+
*
|
|
90
|
+
* A failure is returned as the errno, so a caller can say why the lock cannot be taken.
|
|
78
91
|
*/
|
|
79
92
|
function createExclusive(lockPath, payload) {
|
|
80
93
|
const tmp = `${lockPath}.new-${process.pid}-${Math.random().toString(36).slice(2)}.lock`;
|
|
81
94
|
try {
|
|
82
95
|
writeFileSync(tmp, payload, { flag: 'wx' });
|
|
83
|
-
} catch {
|
|
84
|
-
|
|
96
|
+
} catch (e) {
|
|
97
|
+
// open() may have created it before the write failed (a full disk): do not leave it behind.
|
|
98
|
+
try {
|
|
99
|
+
unlinkSync(tmp);
|
|
100
|
+
} catch {
|
|
101
|
+
/* never created */
|
|
102
|
+
}
|
|
103
|
+
return { error: e.code || 'EIO' };
|
|
85
104
|
}
|
|
86
105
|
try {
|
|
87
106
|
linkSync(tmp, lockPath);
|
|
@@ -93,7 +112,7 @@ function createExclusive(lockPath, payload) {
|
|
|
93
112
|
writeFileSync(lockPath, payload, { flag: 'wx' });
|
|
94
113
|
return 'ok';
|
|
95
114
|
} catch (e2) {
|
|
96
|
-
return e2.code === 'EEXIST' ? 'exists' :
|
|
115
|
+
return e2.code === 'EEXIST' ? 'exists' : { error: e2.code || 'EIO' };
|
|
97
116
|
}
|
|
98
117
|
} finally {
|
|
99
118
|
try {
|
|
@@ -131,10 +150,29 @@ function makeRelease(lockPath, payload) {
|
|
|
131
150
|
* @returns {(() => void)|null} A release() fn, or null if a live peer holds it, the lock file
|
|
132
151
|
* could not be created, or this is a background worker whose lock directory was removed.
|
|
133
152
|
*/
|
|
134
|
-
export function acquireLock(lockPath,
|
|
153
|
+
export function acquireLock(lockPath, opts) {
|
|
154
|
+
return takeLock(lockPath, opts).release || null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* acquireLock, saying why when it does not take the lock. acquireLock's null stands both for a
|
|
159
|
+
* live holder and for a lock file that cannot be created at all, and every installer read that
|
|
160
|
+
* null as "another install is in progress": with a runtime dir a `sudo` run left root-owned, a
|
|
161
|
+
* read-only data dir, a runtime link to nowhere or a full disk, every update skipped silently and
|
|
162
|
+
* install exited 0 having done nothing (D#304, D#307). An error is reported for every failed
|
|
163
|
+
* create: as lockDirBlocked names it when it sees the cause, otherwise with the create's own errno,
|
|
164
|
+
* which covers the causes its access() check cannot see (a Windows ACL, ENOSPC).
|
|
165
|
+
* @returns {{release: () => void} | {error: {dir: string, code: string}} | {}} {} when a live peer
|
|
166
|
+
* holds it, or this is a background worker whose lock directory was removed.
|
|
167
|
+
*/
|
|
168
|
+
export function takeLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.now } = {}) {
|
|
169
|
+
// lockDirBlocked, when it sees the cause, names the directory that has to change and why: it walks
|
|
170
|
+
// to an ancestor that cannot be entered, a link to nowhere, a component that is a file. A cause it
|
|
171
|
+
// cannot see (a full disk, a Windows ACL access() does not read) keeps the create's own errno.
|
|
172
|
+
const failed = (code) => ({ error: lockDirBlocked(lockPath) || { dir: dirname(lockPath), code } });
|
|
135
173
|
// A background worker (auto-maintain takes its lock in the runtime dir) does not re-create a
|
|
136
174
|
// directory removed while it ran (D#265). Not taking the lock is the caller's no-op path.
|
|
137
|
-
if (workerDirGone(dirname(lockPath))) return
|
|
175
|
+
if (workerDirGone(dirname(lockPath))) return {};
|
|
138
176
|
try {
|
|
139
177
|
mkdirSync(dirname(lockPath), { recursive: true });
|
|
140
178
|
} catch {
|
|
@@ -150,8 +188,8 @@ export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.n
|
|
|
150
188
|
nonce: Math.random().toString(36).slice(2),
|
|
151
189
|
});
|
|
152
190
|
const first = createExclusive(lockPath, payload);
|
|
153
|
-
if (first === 'ok') return makeRelease(lockPath, payload);
|
|
154
|
-
if (first
|
|
191
|
+
if (first === 'ok') return { release: makeRelease(lockPath, payload) };
|
|
192
|
+
if (first.error) return failed(first.error); // permission / fs error → fail closed, and say why
|
|
155
193
|
{
|
|
156
194
|
let sampled;
|
|
157
195
|
try {
|
|
@@ -162,9 +200,11 @@ export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.n
|
|
|
162
200
|
// it would move a peer's freshly created lock aside with nothing to compare it to,
|
|
163
201
|
// which is the double-acquire this whole protocol exists to prevent. Just retry the
|
|
164
202
|
// plain exclusive create once and take the answer.
|
|
165
|
-
|
|
203
|
+
const again = createExclusive(lockPath, payload);
|
|
204
|
+
if (again === 'ok') return { release: makeRelease(lockPath, payload) };
|
|
205
|
+
return again.error ? failed(again.error) : {};
|
|
166
206
|
}
|
|
167
|
-
if (!isStale(lockPath, staleMs, now)) return
|
|
207
|
+
if (!isStale(lockPath, staleMs, now)) return {}; // live peer holds it
|
|
168
208
|
// ── Stealing a stale lock (R10 P1-7) ──────────────────────────────────────────────
|
|
169
209
|
// This used to be unlink-then-create, and the comment "lose the race → null" was only
|
|
170
210
|
// true of the create. The losing interleave: A unlinks, A creates, B unlinks A's BRAND
|
|
@@ -182,7 +222,7 @@ export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.n
|
|
|
182
222
|
try {
|
|
183
223
|
renameSync(lockPath, tombstone);
|
|
184
224
|
} catch {
|
|
185
|
-
return
|
|
225
|
+
return {}; // another stealer won the rename; it owns the re-create
|
|
186
226
|
}
|
|
187
227
|
let stolen = null;
|
|
188
228
|
try {
|
|
@@ -201,14 +241,16 @@ export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.n
|
|
|
201
241
|
} catch {
|
|
202
242
|
/* best-effort */
|
|
203
243
|
}
|
|
204
|
-
return
|
|
244
|
+
return {};
|
|
205
245
|
}
|
|
206
246
|
try {
|
|
207
247
|
unlinkSync(tombstone);
|
|
208
248
|
} catch {
|
|
209
249
|
/* best-effort — the .lock suffix keeps it sweepable either way */
|
|
210
250
|
}
|
|
211
|
-
|
|
251
|
+
const retaken = createExclusive(lockPath, payload);
|
|
252
|
+
if (retaken === 'ok') return { release: makeRelease(lockPath, payload) };
|
|
253
|
+
return retaken.error ? failed(retaken.error) : {};
|
|
212
254
|
}
|
|
213
255
|
}
|
|
214
256
|
|
|
@@ -235,6 +277,87 @@ export function lockHeld(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.now
|
|
|
235
277
|
return !isStale(lockPath, staleMs, now);
|
|
236
278
|
}
|
|
237
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Why acquireLock cannot create `lockPath` whoever holds it, or null when it can. acquireLock
|
|
282
|
+
* answers null both for a live holder and for a lock file it cannot create at all, and every
|
|
283
|
+
* caller read that null as "another install is in progress": with a runtime dir a `sudo` run left
|
|
284
|
+
* root-owned, or a read-only data dir, every update skipped silently, install exited 0 having done
|
|
285
|
+
* nothing, and cleanup named a lock that did not exist (D#304, D#307). This tells the two apart.
|
|
286
|
+
* Read-only, like lockHeld: it creates nothing.
|
|
287
|
+
*
|
|
288
|
+
* The directory asked about is the lock's own, or, when that does not exist yet, the nearest one
|
|
289
|
+
* that does: acquireLock creates the rest, so that is the one it has to write into.
|
|
290
|
+
* @returns {{dir: string, code: string}|null}
|
|
291
|
+
*/
|
|
292
|
+
export function lockDirBlocked(lockPath) {
|
|
293
|
+
let dir = dirname(lockPath);
|
|
294
|
+
for (;;) {
|
|
295
|
+
// A path that is there but is a file, not a directory: access() reads the file's own mode bits
|
|
296
|
+
// (EACCES, or a pass for a 0777 file), so it is asked first.
|
|
297
|
+
try {
|
|
298
|
+
if (!statSync(dir).isDirectory()) return { dir, code: 'ENOTDIR' };
|
|
299
|
+
} catch {
|
|
300
|
+
/* absent, or not reachable: access() below says which */
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
accessSync(dir, fsConstants.W_OK | fsConstants.X_OK);
|
|
304
|
+
return null;
|
|
305
|
+
} catch (e) {
|
|
306
|
+
// A link to nowhere reads as absent, but nothing can be created under it (review P2-1).
|
|
307
|
+
if (e.code === 'ENOENT' && isLink(dir)) return { dir, code: 'ENOENT' };
|
|
308
|
+
// A component that is a file, not a directory: name that file.
|
|
309
|
+
if (e.code === 'ENOTDIR') return { dir: firstNonDirectory(dir) ?? dir, code: 'ENOTDIR' };
|
|
310
|
+
// A directory under one that cannot be entered fails EACCES too: name the one to fix.
|
|
311
|
+
if (e.code !== 'ENOENT') return unenterableAncestor(dir) || { dir, code: e.code || 'EACCES' };
|
|
312
|
+
const up = dirname(dir);
|
|
313
|
+
if (up === dir) return null;
|
|
314
|
+
dir = up;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isLink(p) {
|
|
320
|
+
try {
|
|
321
|
+
return lstatSync(p).isSymbolicLink();
|
|
322
|
+
} catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The component of `dir` nearest the root that exists and is not a directory, or null. */
|
|
328
|
+
function firstNonDirectory(dir) {
|
|
329
|
+
const chain = [];
|
|
330
|
+
for (let d = dir; ; d = dirname(d)) {
|
|
331
|
+
chain.unshift(d);
|
|
332
|
+
if (dirname(d) === d) break;
|
|
333
|
+
}
|
|
334
|
+
for (const d of chain) {
|
|
335
|
+
try {
|
|
336
|
+
if (!statSync(d).isDirectory()) return d;
|
|
337
|
+
} catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The ancestor of `dir` nearest the root that this user cannot enter, or null. */
|
|
345
|
+
function unenterableAncestor(dir) {
|
|
346
|
+
const chain = [];
|
|
347
|
+
for (let d = dirname(dir); ; d = dirname(d)) {
|
|
348
|
+
chain.unshift(d);
|
|
349
|
+
if (dirname(d) === d) break;
|
|
350
|
+
}
|
|
351
|
+
for (const d of chain) {
|
|
352
|
+
try {
|
|
353
|
+
accessSync(d, fsConstants.X_OK);
|
|
354
|
+
} catch (e) {
|
|
355
|
+
return e.code === 'ENOENT' ? null : { dir: d, code: e.code || 'EACCES' };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
238
361
|
/**
|
|
239
362
|
* Run `fn` while holding the lock; release in a finally. No-op if not acquired.
|
|
240
363
|
* @returns {{acquired: boolean, result?: any}}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.25.
|
|
3
|
+
"version": "6.25.3",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "6.25.
|
|
9
|
+
"version": "6.25.3",
|
|
10
10
|
"os": [
|
|
11
11
|
"darwin",
|
|
12
12
|
"linux",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.25.
|
|
3
|
+
"version": "6.25.3",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
@@ -114,11 +114,14 @@ try {
|
|
|
114
114
|
process.stderr.write(`[claude-mem-lite] binding probe: ${e.message}\n`);
|
|
115
115
|
process.exit(1);
|
|
116
116
|
}
|
|
117
|
-
const release = helpers.
|
|
117
|
+
const { release, error: blocked } = helpers.takeLock(lockPath);
|
|
118
118
|
if (!release) {
|
|
119
|
+
// A lock that cannot be created at all is not an install in flight (D#307): name it, as doctor does.
|
|
119
120
|
process.stderr.write(
|
|
120
121
|
`[claude-mem-lite] binding probe: ${helpers.flattenBindingError(first.error)} ` +
|
|
121
|
-
|
|
122
|
+
(blocked
|
|
123
|
+
? `(install.lock cannot be taken — ${blocked.dir} cannot be written (${blocked.code}); run doctor for the fix)\n`
|
|
124
|
+
: '(another install/repair in flight — deferring heal)\n'),
|
|
122
125
|
);
|
|
123
126
|
process.exit(1);
|
|
124
127
|
}
|
|
@@ -497,9 +497,7 @@ function healNativeBindingIfBroken() {
|
|
|
497
497
|
// (install.mjs::rebuildBinding). Clearing it here would mean a rebuild that
|
|
498
498
|
// silently did nothing — lock contention, a no-op npm — still reads as
|
|
499
499
|
// "healed", dropping the cooldown and re-spawning npm on every session.
|
|
500
|
-
process.stderr.write(
|
|
501
|
-
'[claude-mem-lite] native DB binding unusable (Node version change?) — rebuilding in the background\n',
|
|
502
|
-
);
|
|
500
|
+
process.stderr.write('[claude-mem-lite] native DB binding unusable — rebuilding in the background\n');
|
|
503
501
|
const child = spawn(process.execPath, [installer, 'rebuild-binding'], {
|
|
504
502
|
detached: true,
|
|
505
503
|
stdio: 'ignore',
|
package/scripts/launch.mjs
CHANGED
|
@@ -116,11 +116,19 @@ try {
|
|
|
116
116
|
// mid-compile. Take the lock for the rebuild-capable path; a live peer →
|
|
117
117
|
// wait up to 10s, then degrade to a probe-only pass (healthy binding
|
|
118
118
|
// proceeds; a broken one defers to the peer instead of racing it).
|
|
119
|
-
const {
|
|
119
|
+
const { takeLock } = await import('../lib/proc-lock.mjs');
|
|
120
120
|
const { resolveDataDir } = await import('../lib/resolve-data-dir.mjs');
|
|
121
121
|
const lockPath = join(resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime', 'install.lock'); // runtime-dir:stays-put — install lock serialises real installers
|
|
122
|
+
// A lock that cannot be created at all (a runtime dir a sudo run left root-owned, a link to
|
|
123
|
+
// nowhere, a full disk) is not a peer to wait for: nothing ever releases it, and waiting cost
|
|
124
|
+
// every MCP launch 10 s (D#307). takeLock says which it is.
|
|
122
125
|
let release = null;
|
|
123
|
-
|
|
126
|
+
let blocked = null;
|
|
127
|
+
for (let i = 0; i < 20; i++) {
|
|
128
|
+
const taken = takeLock(lockPath);
|
|
129
|
+
release = taken.release || null;
|
|
130
|
+
blocked = taken.error || null;
|
|
131
|
+
if (release || blocked) break;
|
|
124
132
|
await new Promise((r) => setTimeout(r, 500));
|
|
125
133
|
}
|
|
126
134
|
let verify;
|
|
@@ -137,7 +145,9 @@ try {
|
|
|
137
145
|
? { ok: true, action: 'verified' }
|
|
138
146
|
: {
|
|
139
147
|
ok: false,
|
|
140
|
-
error:
|
|
148
|
+
error: blocked
|
|
149
|
+
? `${probe.error} (install.lock cannot be taken — ${blocked.dir} cannot be written (${blocked.code}), so it was not rebuilt; \`doctor\` names the fix)`
|
|
150
|
+
: `${probe.error} (another install/repair holds the lock — not rebuilding concurrently; reconnect with /mcp once it finishes)`,
|
|
141
151
|
};
|
|
142
152
|
}
|
|
143
153
|
} finally {
|