claude-mem-lite 6.25.1 → 6.25.2
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-shared.mjs +10 -7
- package/hook-update.mjs +91 -32
- package/hook.mjs +8 -5
- package/install.mjs +92 -37
- package/lib/doctor-stale-temp.mjs +20 -10
- package/lib/proc-lock.mjs +49 -15
- package/lib/wal-open-blocked.mjs +69 -0
- package/mem-cli.mjs +16 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/source-files.mjs +3 -0
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.25.
|
|
12
|
+
"version": "6.25.2",
|
|
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.2",
|
|
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-shared.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { isDbUnusableError, DB_UNUSABLE_MARKER_PREFIX } from './lib/db-unusable.mjs';
|
|
32
32
|
import { shouldRecordOnce } from './lib/record-once.mjs';
|
|
33
33
|
import { backgroundWorkerEnv, workerDirGone } from './lib/worker-data-dir.mjs';
|
|
34
|
+
import { LIVE_HOLDER_MAX_MS } from './lib/proc-lock.mjs';
|
|
34
35
|
import { hookSessionId } from './lib/provenance.mjs';
|
|
35
36
|
import { PROJECT_REKEY_MARKER_PREFIX } from './lib/project-rekey.mjs';
|
|
36
37
|
// Audit 2026-09-05 P1-2 (carried from 2026-09-02 P2-9): `callLLM`, the quiet/adoption
|
|
@@ -67,12 +68,13 @@ export const STALE_SESSION_MS = 24 * 60 * 60 * 1000; // 24h
|
|
|
67
68
|
export const STALE_LOCK_MS = 30000; // 30s
|
|
68
69
|
|
|
69
70
|
// Backstop for cleanStaleLockFiles(): a lock whose recorded pid is ALIVE is kept until it
|
|
70
|
-
// reaches this age, not STALE_LOCK_MS.
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
71
|
+
// reaches this age, not STALE_LOCK_MS. The same bound proc-lock.mjs steals a live holder's lock
|
|
72
|
+
// at, so the sweeper is never the more aggressive of the two — whatever it removes, the lock
|
|
73
|
+
// protocol itself would already have let the next caller steal. Its only job is to
|
|
74
|
+
// garbage-collect a leaked file whose pid was recycled onto an unrelated live process, which
|
|
75
|
+
// would otherwise pin the file forever. (A20260905-R5-P1-1) It was 10 min, shorter than an
|
|
76
|
+
// update holds install.lock while its smoke gate builds the binding from source (D#294).
|
|
77
|
+
export const ABANDONED_LOCK_MS = LIVE_HOLDER_MAX_MS;
|
|
76
78
|
|
|
77
79
|
// The background-maintenance mutex, defined HERE next to the sweeper policy it has to
|
|
78
80
|
// escape. cleanStaleLockFiles() sweeps every `*.lock` in RUNTIME_DIR; until
|
|
@@ -83,7 +85,8 @@ export const ABANDONED_LOCK_MS = 10 * 60 * 1000; // 10 min
|
|
|
83
85
|
// probe, and pid checks are meaningless across a shared homedir. `tests/auto-maintain-proc-lock.test.mjs`
|
|
84
86
|
// asserts that against THIS constant rather than a re-typed copy: the first version of that
|
|
85
87
|
// test built its own path from a literal, so renaming the lock left it green with the hazard
|
|
86
|
-
// back. proc-lock's own staleness policy (
|
|
88
|
+
// back. proc-lock's own staleness policy (a provably-dead pid at once, a live one only past
|
|
89
|
+
// LIVE_HOLDER_MAX_MS) is the correct one.
|
|
87
90
|
export const AUTO_MAINTAIN_LOCK = 'auto-maintain.proclock';
|
|
88
91
|
export const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 min (title dedup)
|
|
89
92
|
export const RELATED_OBS_WINDOW_MS = 7 * DAY_MS; // 7 days
|
package/hook-update.mjs
CHANGED
|
@@ -954,6 +954,16 @@ function retireUnresolvedBackups(targetDir, names) {
|
|
|
954
954
|
}
|
|
955
955
|
}
|
|
956
956
|
|
|
957
|
+
// Backup dirs still unresolved are undone like a stack, newest first (D#293), and their order is
|
|
958
|
+
// carried in the dir NAME, `.update-backup-s<seq>-<ms>-<pid>`: a swap takes one more than the
|
|
959
|
+
// highest seq among the unresolved dirs it finds. Neither the wall clock in the name nor a field in
|
|
960
|
+
// the journal decides it: a clock can go back, and a journal can be unreadable at the moment the
|
|
961
|
+
// order is needed (a scanner holding it on Windows), which made a replay lose a file (review P2-1).
|
|
962
|
+
// A dir named before 6.25.2 has no seq and reads as 0, so it comes last.
|
|
963
|
+
const BACKUP_SEQ_RE = /^\.update-backup-s(\d+)-/;
|
|
964
|
+
const swapSeqOf = (name) => Number(BACKUP_SEQ_RE.exec(name)?.[1] ?? 0);
|
|
965
|
+
const nextSwapSeq = (names) => Math.max(0, ...names.map(swapSeqOf)) + 1;
|
|
966
|
+
|
|
957
967
|
// The `.update-backup-*` dirs in targetDir that carry no resolved marker.
|
|
958
968
|
function unresolvedBackups(targetDir) {
|
|
959
969
|
try {
|
|
@@ -969,8 +979,19 @@ function unresolvedBackups(targetDir) {
|
|
|
969
979
|
|
|
970
980
|
// The backup dir is resolved before staging goes: removing a whole staging tree takes long enough
|
|
971
981
|
// for a kill to land in, and a kill there left a committed swap's journal to be replayed.
|
|
972
|
-
|
|
973
|
-
|
|
982
|
+
//
|
|
983
|
+
// After a rollback, `notRestored` lists what it could not put back. The backup dir then holds the
|
|
984
|
+
// only copy of each, so it stays whole with its journal, unresolved, and the next entry replays it
|
|
985
|
+
// (D#293). The caller used to delete it. Nothing gates a later update on it: one that commits
|
|
986
|
+
// retires it, so a restore that keeps failing cannot block updates (a2cf96a1's gate did).
|
|
987
|
+
function discardSwapDirs(stagingDir, backupDir, notRestored = []) {
|
|
988
|
+
if (notRestored.length === 0) discardBackupDir(backupDir);
|
|
989
|
+
else
|
|
990
|
+
debugLog(
|
|
991
|
+
'WARN',
|
|
992
|
+
'hook-update',
|
|
993
|
+
`Kept ${backupDir}: could not put back ${notRestored.join(', ')}; the next update, install or cleanup tries again`,
|
|
994
|
+
);
|
|
974
995
|
try {
|
|
975
996
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
976
997
|
} catch (e) {
|
|
@@ -978,6 +999,18 @@ function discardSwapDirs(stagingDir, backupDir) {
|
|
|
978
999
|
}
|
|
979
1000
|
}
|
|
980
1001
|
|
|
1002
|
+
// Whether a path is there, by lstat: a symlink counts whatever it points to, and an error other
|
|
1003
|
+
// than "no such path" counts as there. existsSync follows the link and answers false on EACCES, so
|
|
1004
|
+
// a backup that was a dangling symlink read as already put back, and was deleted (review r2 F11).
|
|
1005
|
+
function present(p) {
|
|
1006
|
+
try {
|
|
1007
|
+
lstatSync(p);
|
|
1008
|
+
return true;
|
|
1009
|
+
} catch (e) {
|
|
1010
|
+
return e.code !== 'ENOENT' && e.code !== 'ENOTDIR';
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
981
1014
|
/**
|
|
982
1015
|
* Finish any swap a previous process was killed in the middle of, then clear its
|
|
983
1016
|
* residue. Called on every install entry, under the install lock, BEFORE a new
|
|
@@ -995,6 +1028,7 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
|
|
|
995
1028
|
}
|
|
996
1029
|
|
|
997
1030
|
let recovered = 0;
|
|
1031
|
+
const pending = [];
|
|
998
1032
|
for (const entry of entries) {
|
|
999
1033
|
if (!entry.isDirectory()) continue;
|
|
1000
1034
|
const dir = join(targetDir, entry.name);
|
|
@@ -1017,42 +1051,63 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
|
|
|
1017
1051
|
recovered++;
|
|
1018
1052
|
continue;
|
|
1019
1053
|
}
|
|
1054
|
+
pending.push(entry.name);
|
|
1055
|
+
}
|
|
1020
1056
|
|
|
1057
|
+
// Several dirs are unresolved only when a rollback could not put every file back (D#293), and
|
|
1058
|
+
// they are undone like a stack: newest first by the seq in the name, and none older while a newer
|
|
1059
|
+
// one is unfinished or cannot be read. An older journal replayed first puts back a file that a
|
|
1060
|
+
// newer journal, which installed that path, then deletes; with nothing left to restore it from.
|
|
1061
|
+
pending.sort((a, b) => swapSeqOf(b) - swapSeqOf(a) || b.localeCompare(a));
|
|
1062
|
+
for (const name of pending) {
|
|
1063
|
+
const dir = join(targetDir, name);
|
|
1021
1064
|
// No journal: the swap is over and its journal is gone (or it was killed before its first
|
|
1022
1065
|
// rename), so there is nothing to replay and the dir is residue. A journal that is there but
|
|
1023
1066
|
// cannot be read, or not as one (an in-place write of 6.25.0 or older torn by a kill), says
|
|
1024
|
-
// nothing about what moved, and the dir may hold the only copy of it: leave it
|
|
1025
|
-
// read as "nothing moved" and deleted (v6.25.1 review). doctor and
|
|
1026
|
-
// swap that commits, or an install, retires it.
|
|
1027
|
-
let
|
|
1028
|
-
let raw = null;
|
|
1067
|
+
// nothing about what moved, and the dir may hold the only copy of it: leave it, and everything
|
|
1068
|
+
// older with it. It used to be read as "nothing moved" and deleted (v6.25.1 review). doctor and
|
|
1069
|
+
// cleanup name it; the next swap that commits, or an install, retires it.
|
|
1070
|
+
let raw;
|
|
1029
1071
|
try {
|
|
1030
1072
|
raw = readFileSync(join(dir, SWAP_JOURNAL), 'utf8');
|
|
1031
1073
|
} catch (e) {
|
|
1032
|
-
if (e.code
|
|
1033
|
-
|
|
1074
|
+
if (e.code === 'ENOENT') {
|
|
1075
|
+
discardBackupDir(dir);
|
|
1076
|
+
recovered++;
|
|
1034
1077
|
continue;
|
|
1035
1078
|
}
|
|
1079
|
+
debugCatch(e, 'recover-read-journal');
|
|
1080
|
+
break;
|
|
1036
1081
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1082
|
+
let journal;
|
|
1083
|
+
try {
|
|
1084
|
+
journal = JSON.parse(raw);
|
|
1085
|
+
} catch {
|
|
1086
|
+
journal = null;
|
|
1087
|
+
}
|
|
1088
|
+
if (!Array.isArray(journal?.backedUp) || !Array.isArray(journal?.installed)) {
|
|
1089
|
+
debugLog(
|
|
1090
|
+
'WARN',
|
|
1091
|
+
'hook-update',
|
|
1092
|
+
`Left ${name} in place, and any older one: its swap journal cannot be read`,
|
|
1093
|
+
);
|
|
1094
|
+
break;
|
|
1095
|
+
}
|
|
1096
|
+
const notRestored = rollbackInstall(journal.installed, journal.backedUp, dir, targetDir);
|
|
1097
|
+
if (notRestored.length > 0) {
|
|
1098
|
+
debugLog(
|
|
1099
|
+
'WARN',
|
|
1100
|
+
'hook-update',
|
|
1101
|
+
`Could not finish ${name}: ${notRestored.join(', ')} not put back; left, with any older one, for the next entry`,
|
|
1102
|
+
);
|
|
1103
|
+
break;
|
|
1047
1104
|
}
|
|
1048
|
-
const { backedUp, installed } = journal;
|
|
1049
|
-
rollbackInstall(installed, backedUp, dir, targetDir);
|
|
1050
1105
|
discardBackupDir(dir);
|
|
1051
1106
|
recovered++;
|
|
1052
1107
|
debugLog(
|
|
1053
1108
|
'WARN',
|
|
1054
1109
|
'hook-update',
|
|
1055
|
-
`Recovered an interrupted update swap: restored ${backedUp.length} path(s) from ${
|
|
1110
|
+
`Recovered an interrupted update swap: restored ${journal.backedUp.length} path(s) from ${name}`,
|
|
1056
1111
|
);
|
|
1057
1112
|
}
|
|
1058
1113
|
return recovered;
|
|
@@ -1069,10 +1124,12 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
|
|
|
1069
1124
|
// when the journal cannot be removed, the resolved marker keeps it from being replayed. A backup
|
|
1070
1125
|
// dir 6.25.0 or older left half deleted beside its journal can still replay to a mix.
|
|
1071
1126
|
// The arrays are copied, not reversed in place, so a caller's journal is left as it was.
|
|
1127
|
+
//
|
|
1128
|
+
// Returns the backed-up paths it could not put back, still in the backup dir (D#293).
|
|
1072
1129
|
function rollbackInstall(installed, backedUp, backupDir, targetDir) {
|
|
1073
1130
|
markSwapStart();
|
|
1074
1131
|
try {
|
|
1075
|
-
const restored = new Set(backedUp.filter((relPath) => !
|
|
1132
|
+
const restored = new Set(backedUp.filter((relPath) => !present(join(backupDir, relPath))));
|
|
1076
1133
|
for (const relPath of [...installed].reverse()) {
|
|
1077
1134
|
if (restored.has(relPath)) continue;
|
|
1078
1135
|
try {
|
|
@@ -1085,7 +1142,7 @@ function rollbackInstall(installed, backedUp, backupDir, targetDir) {
|
|
|
1085
1142
|
const backupPath = join(backupDir, relPath);
|
|
1086
1143
|
const targetPath = join(targetDir, relPath);
|
|
1087
1144
|
try {
|
|
1088
|
-
if (
|
|
1145
|
+
if (present(backupPath)) {
|
|
1089
1146
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
1090
1147
|
renameSync(backupPath, targetPath);
|
|
1091
1148
|
}
|
|
@@ -1096,6 +1153,7 @@ function rollbackInstall(installed, backedUp, backupDir, targetDir) {
|
|
|
1096
1153
|
} finally {
|
|
1097
1154
|
clearSwapMarker();
|
|
1098
1155
|
}
|
|
1156
|
+
return backedUp.filter((relPath) => present(join(backupDir, relPath)));
|
|
1099
1157
|
}
|
|
1100
1158
|
|
|
1101
1159
|
// MED-5 post-install health gate: load-test the freshly-switched code in a SEPARATE
|
|
@@ -1194,7 +1252,8 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1194
1252
|
}
|
|
1195
1253
|
const ts = `${Date.now()}-${process.pid}`;
|
|
1196
1254
|
const stagingDir = join(targetDir, `.update-staging-${ts}`);
|
|
1197
|
-
|
|
1255
|
+
// Named once the seq is known, below; until then nothing has been written into it.
|
|
1256
|
+
let backupDir = join(targetDir, `.update-backup-${ts}`);
|
|
1198
1257
|
const backedUp = [];
|
|
1199
1258
|
const installed = [];
|
|
1200
1259
|
let committed = false;
|
|
@@ -1207,9 +1266,11 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1207
1266
|
// one — otherwise this install stacks on top of a mixed-version tree and its
|
|
1208
1267
|
// own backup can no longer restore a coherent state.
|
|
1209
1268
|
recoverInterruptedSwaps(targetDir);
|
|
1210
|
-
// What recovery left unfinished (a journal it cannot read right now
|
|
1211
|
-
// commits, and left for the next entry if it
|
|
1269
|
+
// What recovery left unfinished (a journal it cannot read right now, or a replay that could not
|
|
1270
|
+
// put every file back, D#293) is retired if this swap commits, and left for the next entry if it
|
|
1271
|
+
// rolls back. Nothing here waits on it: a gate on it blocked every later update (a2cf96a1).
|
|
1212
1272
|
const leftUnresolved = unresolvedBackups(targetDir);
|
|
1273
|
+
backupDir = join(targetDir, `.update-backup-s${nextSwapSeq(leftUnresolved)}-${ts}`);
|
|
1213
1274
|
|
|
1214
1275
|
mkdirSync(stagingDir, { recursive: true });
|
|
1215
1276
|
mkdirSync(backupDir, { recursive: true });
|
|
@@ -1238,7 +1299,7 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1238
1299
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
1239
1300
|
mkdirSync(dirname(backupPath), { recursive: true });
|
|
1240
1301
|
|
|
1241
|
-
if (
|
|
1302
|
+
if (present(targetPath)) {
|
|
1242
1303
|
backedUp.push(relPath);
|
|
1243
1304
|
journalSwap(backupDir, backedUp, installed);
|
|
1244
1305
|
renameSync(targetPath, backupPath);
|
|
@@ -1257,8 +1318,7 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1257
1318
|
// If it can't, restore the backup and report failure — the running (old) version
|
|
1258
1319
|
// keeps working rather than leaving a broken install with no way back.
|
|
1259
1320
|
if (!opts.skipSmoke && !smokeInstalledRelease(targetDir)) {
|
|
1260
|
-
rollbackInstall(installed, backedUp, backupDir, targetDir);
|
|
1261
|
-
discardSwapDirs(stagingDir, backupDir);
|
|
1321
|
+
discardSwapDirs(stagingDir, backupDir, rollbackInstall(installed, backedUp, backupDir, targetDir));
|
|
1262
1322
|
return false;
|
|
1263
1323
|
}
|
|
1264
1324
|
|
|
@@ -1354,8 +1414,7 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1354
1414
|
// Past the commit point the backups are gone and the new release IS the install: a rollback
|
|
1355
1415
|
// would delete the paths it added and restore nothing (D#283).
|
|
1356
1416
|
if (committed) return true;
|
|
1357
|
-
rollbackInstall(installed, backedUp, backupDir, targetDir);
|
|
1358
|
-
discardSwapDirs(stagingDir, backupDir);
|
|
1417
|
+
discardSwapDirs(stagingDir, backupDir, rollbackInstall(installed, backedUp, backupDir, targetDir));
|
|
1359
1418
|
return false;
|
|
1360
1419
|
} finally {
|
|
1361
1420
|
release();
|
package/hook.mjs
CHANGED
|
@@ -2287,10 +2287,12 @@ function scheduleSessionStartAutoMaintain(project) {
|
|
|
2287
2287
|
// to minutes (VACUUM INTO snapshot, purge, decay, dedup over the whole DB). It now spares a
|
|
2288
2288
|
// live holder (A20260905-R5-P1-1), but this escape stays: not being swept is a stronger
|
|
2289
2289
|
// guarantee than being spared by a pid probe, and pids are meaningless across a shared
|
|
2290
|
-
// homedir. proc-lock brings its own staleness policy (
|
|
2291
|
-
// the correct one here.
|
|
2292
|
-
//
|
|
2293
|
-
//
|
|
2290
|
+
// homedir. proc-lock brings its own staleness policy (a provably-dead pid at once, a live one
|
|
2291
|
+
// only past LIVE_HOLDER_MAX_MS), which is the correct one here.
|
|
2292
|
+
// Since D#294 this bound applies only to a lock that records no pid, which acquireLock never
|
|
2293
|
+
// writes: a crashed holder is reclaimed through its dead pid, and a pid that looks alive (a
|
|
2294
|
+
// recycled one, or one from another host on a shared homedir) holds the lock for
|
|
2295
|
+
// LIVE_HOLDER_MAX_MS, 1 h, not 10 min.
|
|
2294
2296
|
const AUTO_MAINTAIN_LOCK_STALE_MS = 10 * 60 * 1000;
|
|
2295
2297
|
|
|
2296
2298
|
// Detached `auto-maintain` worker entry: opens its own DB and runs the maintenance
|
|
@@ -2448,7 +2450,8 @@ function saveHandoffAndFastSummary(
|
|
|
2448
2450
|
*
|
|
2449
2451
|
* Liveness of the two real lock families does not depend on this sweeper, so tightening it
|
|
2450
2452
|
* cannot wedge either: hook-episode.acquireLock() preempts a >30s episode lock itself at
|
|
2451
|
-
* acquire time, and proc-lock.acquireLock() steals on
|
|
2453
|
+
* acquire time, and proc-lock.acquireLock() steals on a dead pid at once and on age only past
|
|
2454
|
+
* LIVE_HOLDER_MAX_MS (D#294), the same bound as ABANDONED_LOCK_MS here.
|
|
2452
2455
|
*/
|
|
2453
2456
|
function cleanStaleLockFiles() {
|
|
2454
2457
|
try {
|
package/install.mjs
CHANGED
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
readdirSync,
|
|
17
17
|
statSync,
|
|
18
18
|
lstatSync,
|
|
19
|
-
accessSync,
|
|
20
19
|
constants as fsConstants,
|
|
21
20
|
} from 'fs';
|
|
22
21
|
import { join, resolve, dirname, basename, sep } from 'path';
|
|
@@ -98,9 +97,10 @@ import {
|
|
|
98
97
|
import { detectInstallShape, probeRuntimeRoots, hasAnyManagedCode } from './lib/install-shape.mjs';
|
|
99
98
|
import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
|
|
100
99
|
import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
|
|
101
|
-
import { acquireLock } from './lib/proc-lock.mjs';
|
|
100
|
+
import { acquireLock, lockHeld } from './lib/proc-lock.mjs';
|
|
102
101
|
import { atomicWriteFileSync, atomicCopyFileSync } from './lib/atomic-write.mjs';
|
|
103
102
|
import { shellWord } from './cli-path.mjs';
|
|
103
|
+
import { dirAccessError, walOpenBlocked, dataDirRemedy, WAL_BLOCKED_WHY } from './lib/wal-open-blocked.mjs';
|
|
104
104
|
import {
|
|
105
105
|
claudeConfigDir,
|
|
106
106
|
claudeConfigDirFor,
|
|
@@ -1328,8 +1328,10 @@ function listUpdateResidue() {
|
|
|
1328
1328
|
* tree is written, or for a later entry that can replay it).
|
|
1329
1329
|
* @param {{f: string, kind: string}[]} found entries from listUpdateResidue
|
|
1330
1330
|
* @returns {Promise<{finished: string[], removed: string[], kept: string[], deferred: string[],
|
|
1331
|
-
* left: string[]}>} kept: left on purpose because its journal cannot be
|
|
1332
|
-
* unfinished swap left because hook-update could not load;
|
|
1331
|
+
* unfinished: string[], left: string[]}>} kept: left on purpose because its journal cannot be
|
|
1332
|
+
* read; deferred: an unfinished swap left because hook-update could not load; unfinished: one the
|
|
1333
|
+
* replay did not finish, because a file could not be put back from it, or a later one that must be
|
|
1334
|
+
* finished first could not be finished or read (D#293); left: anything else
|
|
1333
1335
|
*/
|
|
1334
1336
|
async function recoverUpdateResidue(found) {
|
|
1335
1337
|
let replayed = true;
|
|
@@ -1350,11 +1352,13 @@ async function recoverUpdateResidue(found) {
|
|
|
1350
1352
|
/* reported below as left */
|
|
1351
1353
|
}
|
|
1352
1354
|
}
|
|
1353
|
-
const out = { finished: [], removed: [], kept: [], deferred: [], left: [] };
|
|
1355
|
+
const out = { finished: [], removed: [], kept: [], deferred: [], unfinished: [], left: [] };
|
|
1354
1356
|
for (const { f, kind } of found) {
|
|
1355
1357
|
if (!pathPresent(join(INSTALL_DIR, f))) (kind === 'unfinished-swap' ? out.finished : out.removed).push(f);
|
|
1356
1358
|
else if (kind === 'unreadable-journal') out.kept.push(f);
|
|
1357
1359
|
else if (kind === 'unfinished-swap' && !replayed) out.deferred.push(f);
|
|
1360
|
+
else if (kind === 'unfinished-swap' && classifyUpdateResidue(INSTALL_DIR, f) === 'unfinished-swap')
|
|
1361
|
+
out.unfinished.push(f);
|
|
1358
1362
|
else out.left.push(f);
|
|
1359
1363
|
}
|
|
1360
1364
|
return out;
|
|
@@ -1375,7 +1379,11 @@ async function finishInterruptedSwaps() {
|
|
|
1375
1379
|
const r = await recoverUpdateResidue(found);
|
|
1376
1380
|
if (r.finished.length > 0)
|
|
1377
1381
|
ok(`Finished ${r.finished.length} interrupted update(s) before installing: ${r.finished.join(', ')}`);
|
|
1378
|
-
// A swap left unfinished here (deferred, kept) is retired once the tree is written.
|
|
1382
|
+
// A swap left unfinished here (deferred, kept, unfinished) is retired once the tree is written.
|
|
1383
|
+
for (const f of r.unfinished)
|
|
1384
|
+
warn(
|
|
1385
|
+
`Could not finish the interrupted update ${f}: a file it moved aside could not be put back, or a later one that must be finished first could not be. This install writes over it and then retires it.`,
|
|
1386
|
+
);
|
|
1379
1387
|
for (const f of r.left) warn(`Could not remove ${f} from ${INSTALL_DIR}`);
|
|
1380
1388
|
}
|
|
1381
1389
|
|
|
@@ -1896,10 +1904,12 @@ async function status() {
|
|
|
1896
1904
|
// SQLite creates beside the DB: in a directory it cannot write, a closed store cannot be opened
|
|
1897
1905
|
// in place (a copy elsewhere could be read) — 6.24.0 could not open it either. Say so rather than relay "attempt to write a readonly database".
|
|
1898
1906
|
// The errno stands in for SQLite's message only where the directory is the cause.
|
|
1899
|
-
const walBlocked =
|
|
1907
|
+
const walBlocked = walOpenBlocked(e.code, dataDirDenied, DB_PATH);
|
|
1900
1908
|
const why = walBlocked
|
|
1901
|
-
?
|
|
1902
|
-
:
|
|
1909
|
+
? `exists, but ${WAL_BLOCKED_WHY}`
|
|
1910
|
+
: bindingMissing(e)
|
|
1911
|
+
? `exists, but not checked — ${BINDING_MISSING_WHY}`
|
|
1912
|
+
: 'exists but check failed — ' + e.message;
|
|
1903
1913
|
push(dataDirDenied ? 'fail' : 'warn', 'database', `Database: ${why}${unwritable}`, {
|
|
1904
1914
|
exists: true,
|
|
1905
1915
|
error: walBlocked ? dataDirDenied : e.message,
|
|
@@ -2138,10 +2148,10 @@ async function doctor() {
|
|
|
2138
2148
|
// create the -wal/-shm files beside it, so every check that opens the DB fails, for the fault the
|
|
2139
2149
|
// Data directory line names and counts. Those checks say they did not look, in the words status
|
|
2140
2150
|
// uses (D#287, the D#199 shape). Keyed on SQLite's code: its message is shared with other faults.
|
|
2141
|
-
const walBlocked = (code) =>
|
|
2151
|
+
const walBlocked = (code) => walOpenBlocked(code, dataDirDenied, DB_PATH);
|
|
2142
2152
|
const notCheckedWal = (what, scope = '') =>
|
|
2143
2153
|
dwarn(
|
|
2144
|
-
`${what}: not checked${scope} —
|
|
2154
|
+
`${what}: not checked${scope} — ${WAL_BLOCKED_WHY}, and ${MEM_DATA_DIR} cannot be written (see Data directory above)`,
|
|
2145
2155
|
);
|
|
2146
2156
|
// The code dir is the data dir in the default shape; under CLAUDE_MEM_DIR it is checked apart.
|
|
2147
2157
|
const codeIsDataDir = sameDir(INSTALL_DIR, MEM_DATA_DIR);
|
|
@@ -2501,9 +2511,17 @@ async function doctor() {
|
|
|
2501
2511
|
// with require-error noise every session. README's Uninstall section warns
|
|
2502
2512
|
// about the right ordering; this check flags the broken state so it surfaces
|
|
2503
2513
|
// even when the user skipped the README.
|
|
2504
|
-
const
|
|
2514
|
+
const unreadableHookPaths = [];
|
|
2515
|
+
const orphanPaths =
|
|
2516
|
+
settings === null ? null : collectOrphanHookPaths(settings, INSTALL_DIR, unreadableHookPaths);
|
|
2505
2517
|
if (orphanPaths === null) {
|
|
2506
2518
|
dwarn('Orphan hooks: not checked (settings.json unreadable)');
|
|
2519
|
+
} else if (orphanPaths.length === 0 && unreadableHookPaths.length > 0) {
|
|
2520
|
+
// A hook that points into a code home this user cannot enter is not an orphan: the file may
|
|
2521
|
+
// well be there, and the Entry points line names and counts the locked dir (D#297).
|
|
2522
|
+
dwarn(
|
|
2523
|
+
`Orphan hooks: not checked — ${unreadableHookPaths.length} hook target(s) cannot be read (${unreadableHookPaths[0]}${unreadableHookPaths.length > 1 ? ', …' : ''})`,
|
|
2524
|
+
);
|
|
2507
2525
|
} else if (orphanPaths.length > 0) {
|
|
2508
2526
|
fail(
|
|
2509
2527
|
`Orphan hooks: ${orphanPaths.length} settings.json entr${orphanPaths.length === 1 ? 'y references a missing file' : 'ies reference missing files'}`,
|
|
@@ -2643,6 +2661,8 @@ async function doctor() {
|
|
|
2643
2661
|
} catch (e) {
|
|
2644
2662
|
if (walBlocked(e.code)) {
|
|
2645
2663
|
notCheckedWal('Database');
|
|
2664
|
+
} else if (bindingMissing(e)) {
|
|
2665
|
+
dwarn(`Database: not checked — ${BINDING_MISSING_WHY}`);
|
|
2646
2666
|
} else {
|
|
2647
2667
|
fail('Database: ' + e.message);
|
|
2648
2668
|
// Every other ✗ on this screen carries a remedy; this one used to be the exception,
|
|
@@ -3004,21 +3024,22 @@ async function doctor() {
|
|
|
3004
3024
|
// file. Counting is all that differs here; the classification is shared, so "what doctor
|
|
3005
3025
|
// calls stale" and "what cleanup removes" agree on the age gate and on the directory, the
|
|
3006
3026
|
// axes they diverged on (the third time, D#289: both looked for update residue in the data
|
|
3007
|
-
// dir, which CLAUDE_MEM_DIR moves away from the code dir it lives in).
|
|
3008
|
-
// cleanup skips update residue
|
|
3009
|
-
//
|
|
3010
|
-
//
|
|
3011
|
-
// running update looks the same.
|
|
3027
|
+
// dir, which CLAUDE_MEM_DIR moves away from the code dir it lives in). And on the lock: while
|
|
3028
|
+
// install.lock is held, cleanup skips update residue (a running update's own staging and backup
|
|
3029
|
+
// dirs among it), so doctor does not count it either, and says it did not look (D#297). The holder
|
|
3030
|
+
// may be any installer: an update, install, repair, a binding rebuild. It asks without taking it.
|
|
3012
3031
|
try {
|
|
3032
|
+
const lockHeldNow = lockHeld(join(MEM_DATA_DIR, 'runtime', 'install.lock')); // runtime-dir:stays-put — install lock serialises real installers
|
|
3013
3033
|
const { stale, inFlight, unfinishedSwaps, unreadableJournals, notChecked } = scanStaleTempFiles({
|
|
3014
3034
|
codeDir: INSTALL_DIR,
|
|
3015
3035
|
runtimeDir: MEM_RUNTIME_DIR,
|
|
3016
3036
|
legacyDir: codeIsDataDir ? undefined : MEM_DATA_DIR,
|
|
3037
|
+
installLockHeld: lockHeldNow,
|
|
3017
3038
|
});
|
|
3018
3039
|
const installer = `node ${shellWord(join(PROJECT_DIR, 'install.mjs'))}`;
|
|
3019
3040
|
if (unfinishedSwaps > 0) {
|
|
3020
3041
|
dwarn(
|
|
3021
|
-
`Unfinished update: ${unfinishedSwaps} backup dir(s) in ${INSTALL_DIR} still hold a swap journal — an update was interrupted, or
|
|
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)`,
|
|
3022
3043
|
);
|
|
3023
3044
|
}
|
|
3024
3045
|
if (unreadableJournals > 0) {
|
|
@@ -3026,14 +3047,20 @@ async function doctor() {
|
|
|
3026
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)`,
|
|
3027
3048
|
);
|
|
3028
3049
|
}
|
|
3050
|
+
const skipped = lockHeldNow
|
|
3051
|
+
? `update residue not checked — install.lock is held (by a running install, update, repair or binding rebuild, or as a lock file this user cannot read), and cleanup skips it too`
|
|
3052
|
+
: '';
|
|
3029
3053
|
if (notChecked.length > 0) {
|
|
3030
3054
|
const where = notChecked.map((n) => `${n.dir} is not accessible (${n.code})`).join('; ');
|
|
3031
3055
|
dwarn(
|
|
3032
3056
|
`Stale temp files: not checked — ${where}` +
|
|
3033
|
-
(stale > 0 ? `; ${stale} found elsewhere (run: ${installer} cleanup)` : '')
|
|
3057
|
+
(stale > 0 ? `; ${stale} found elsewhere (run: ${installer} cleanup)` : '') +
|
|
3058
|
+
(skipped ? `; ${skipped}` : ''),
|
|
3034
3059
|
);
|
|
3035
3060
|
} else if (stale > 0) {
|
|
3036
|
-
dwarn(`Stale temp files: ${stale} found (run: ${installer} cleanup)`);
|
|
3061
|
+
dwarn(`Stale temp files: ${stale} found (run: ${installer} cleanup)${skipped ? `; ${skipped}` : ''}`);
|
|
3062
|
+
} else if (skipped) {
|
|
3063
|
+
dwarn(`Stale temp files: ${skipped}`);
|
|
3037
3064
|
} else {
|
|
3038
3065
|
ok('Stale temp files: none');
|
|
3039
3066
|
}
|
|
@@ -3076,6 +3103,7 @@ async function doctor() {
|
|
|
3076
3103
|
else ok(stats);
|
|
3077
3104
|
} catch (e) {
|
|
3078
3105
|
if (walBlocked(e.code)) notCheckedWal('DB stats');
|
|
3106
|
+
else if (bindingMissing(e)) dwarn(`DB stats: not checked — ${BINDING_MISSING_WHY}`);
|
|
3079
3107
|
else dwarn('DB stats: ' + e.message);
|
|
3080
3108
|
}
|
|
3081
3109
|
}
|
|
@@ -3201,9 +3229,31 @@ function looksLikeHookPath(p) {
|
|
|
3201
3229
|
return HOOK_PATH_EXTS.some((ext) => p.endsWith(ext));
|
|
3202
3230
|
}
|
|
3203
3231
|
|
|
3204
|
-
|
|
3232
|
+
// 'missing' when the path is not there, 'unreadable' when it cannot be looked at (a directory on the
|
|
3233
|
+
// way this user cannot enter). existsSync answers false to both, so hooks pointing into a locked code
|
|
3234
|
+
// home were orphans, a second ✗ beside the Entry points line that names the lock (D#297).
|
|
3235
|
+
function hookTargetState(p) {
|
|
3236
|
+
try {
|
|
3237
|
+
statSync(p);
|
|
3238
|
+
return 'present';
|
|
3239
|
+
} catch (e) {
|
|
3240
|
+
return e.code === 'ENOENT' || e.code === 'ENOTDIR' ? 'missing' : 'unreadable';
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
/**
|
|
3245
|
+
* @param {string[]|null} [unreadable] collects the targets that could not be looked at, which are
|
|
3246
|
+
* not returned as missing
|
|
3247
|
+
*/
|
|
3248
|
+
export function collectOrphanHookPaths(settings, installDir = INSTALL_DIR, unreadable = null) {
|
|
3205
3249
|
if (!settings?.hooks) return [];
|
|
3206
3250
|
const out = [];
|
|
3251
|
+
const judge = (p) => {
|
|
3252
|
+
if (out.includes(p) || unreadable?.includes(p)) return;
|
|
3253
|
+
const state = hookTargetState(p);
|
|
3254
|
+
if (state === 'missing') out.push(p);
|
|
3255
|
+
else if (state === 'unreadable') unreadable?.push(p);
|
|
3256
|
+
};
|
|
3207
3257
|
for (const configs of Object.values(settings.hooks)) {
|
|
3208
3258
|
if (!Array.isArray(configs)) continue;
|
|
3209
3259
|
for (const cfg of configs) {
|
|
@@ -3220,7 +3270,7 @@ export function collectOrphanHookPaths(settings, installDir = INSTALL_DIR) {
|
|
|
3220
3270
|
// ERR_MODULE_NOT_FOUND — and doctor went back to printing "Orphan hooks: none".
|
|
3221
3271
|
// Both files have to exist for the hook to run, so both are reportable.
|
|
3222
3272
|
const entry = launcherEntryPath(cmd, installDir);
|
|
3223
|
-
if (entry
|
|
3273
|
+
if (entry) judge(entry);
|
|
3224
3274
|
// v2.80: scan ALL quoted tokens (was: only the first), prefer ones
|
|
3225
3275
|
// that look like a hook path. Fixes a footgun where a wrapper command
|
|
3226
3276
|
// like `bash -c "some inline" "/real/path.sh"` would pick "some inline"
|
|
@@ -3239,7 +3289,7 @@ export function collectOrphanHookPaths(settings, installDir = INSTALL_DIR) {
|
|
|
3239
3289
|
path = parts.find((p) => looksLikeHookPath(p)) || null;
|
|
3240
3290
|
}
|
|
3241
3291
|
if (!path) continue;
|
|
3242
|
-
|
|
3292
|
+
judge(path);
|
|
3243
3293
|
}
|
|
3244
3294
|
}
|
|
3245
3295
|
}
|
|
@@ -3452,7 +3502,10 @@ async function cleanup() {
|
|
|
3452
3502
|
// and doctor tells the user to run cleanup. Non-blocking: if an installer holds the lock we skip
|
|
3453
3503
|
// only the update residue, not the rest of cleanup.
|
|
3454
3504
|
let finished = 0;
|
|
3455
|
-
|
|
3505
|
+
// --dry-run writes nothing, so it asks whether the lock is held instead of taking it: taking it
|
|
3506
|
+
// created the data dir, its runtime dir and the lock on a machine with nothing installed (D#297).
|
|
3507
|
+
const lockPath = join(MEM_DATA_DIR, 'runtime', 'install.lock'); // runtime-dir:stays-put — install lock serialises real installers
|
|
3508
|
+
const updateLock = dryRun ? (lockHeld(lockPath) ? null : () => {}) : acquireLock(lockPath);
|
|
3456
3509
|
if (!updateLock) {
|
|
3457
3510
|
warn('Update residue skipped: install in progress (install.lock held)');
|
|
3458
3511
|
} else {
|
|
@@ -3484,6 +3537,10 @@ async function cleanup() {
|
|
|
3484
3537
|
warn(
|
|
3485
3538
|
`Left in place: ${f} — its journal cannot be read, so nothing can finish that update, and it holds the files the update moved aside. repair reinstalls over it; cleanup removes it after that.`,
|
|
3486
3539
|
);
|
|
3540
|
+
for (const f of r.unfinished)
|
|
3541
|
+
warn(
|
|
3542
|
+
`Could not finish the interrupted update ${f}: a file it moved aside could not be put back (held open, or in a directory that cannot be emptied), or a later one that must be finished first could not be. It stays in place holding what it moved aside; cleanup tries again, and repair reinstalls over it instead.`,
|
|
3543
|
+
);
|
|
3487
3544
|
for (const f of r.left) warn(`Failed to remove ${f}`);
|
|
3488
3545
|
finished += r.finished.length;
|
|
3489
3546
|
removed += r.removed.length;
|
|
@@ -3936,12 +3993,7 @@ function dataDirAccessError(
|
|
|
3936
3993
|
dir = MEM_DATA_DIR,
|
|
3937
3994
|
mode = fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK,
|
|
3938
3995
|
) {
|
|
3939
|
-
|
|
3940
|
-
accessSync(dir, mode);
|
|
3941
|
-
return null;
|
|
3942
|
-
} catch (e) {
|
|
3943
|
-
return e.code === 'ENOENT' ? null : e.code || 'EACCES';
|
|
3944
|
-
}
|
|
3996
|
+
return dirAccessError(dir, mode);
|
|
3945
3997
|
}
|
|
3946
3998
|
|
|
3947
3999
|
// Same directory however it is spelled (a trailing slash, a symlinked HOME). stat works on a
|
|
@@ -3956,13 +4008,16 @@ function sameDir(a, b) {
|
|
|
3956
4008
|
}
|
|
3957
4009
|
}
|
|
3958
4010
|
|
|
3959
|
-
// The fix for a data dir this user cannot use, chosen by the error code.
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
4011
|
+
// The fix for a data dir this user cannot use, chosen by the error code (lib/wal-open-blocked.mjs).
|
|
4012
|
+
export const dataDirAccessRemedy = (code) => dataDirRemedy(code, MEM_DATA_DIR);
|
|
4013
|
+
|
|
4014
|
+
// This process cannot load better-sqlite3: none installed where it runs (a checkout, or a plugin
|
|
4015
|
+
// cache before its first launch installed node_modules), or one that will not load (a stale ABI, a
|
|
4016
|
+
// damaged prebuild). The better-sqlite3 line names and counts that; a line that would open the DB
|
|
4017
|
+
// says it did not look, rather than count the same fault again (D#297, pre-ship review P3-4).
|
|
4018
|
+
const bindingMissing = (e) =>
|
|
4019
|
+
(e?.code === 'ERR_MODULE_NOT_FOUND' && /'better-sqlite3'/.test(e.message)) || isNativeBindingError(e);
|
|
4020
|
+
const BINDING_MISSING_WHY = `better-sqlite3 cannot be loaded from ${PROJECT_DIR}`;
|
|
3966
4021
|
|
|
3967
4022
|
async function runLockedInstall() {
|
|
3968
4023
|
const denied = dataDirAccessError();
|
|
@@ -64,8 +64,9 @@ export const isEpisodeResidue = (name) => EPISODE_RESIDUE_PREFIXES.some((p) => n
|
|
|
64
64
|
* is residue. A dir from 6.25.0 or older carries no marker; one whose journal survived a finished
|
|
65
65
|
* swap there still reads as unfinished.
|
|
66
66
|
*
|
|
67
|
-
* A swap running right now looks the same as one that was killed
|
|
68
|
-
* install.lock
|
|
67
|
+
* A swap running right now looks the same as one that was killed, and nothing tells them apart:
|
|
68
|
+
* while install.lock is held, cleanup and doctor both leave all update residue alone (doctor asks
|
|
69
|
+
* without taking the lock, through `installLockHeld` below).
|
|
69
70
|
*
|
|
70
71
|
* 'unreadable-journal' is an unresolved backup dir whose journal is there but cannot be read now,
|
|
71
72
|
* or not as one (a write of 6.25.0 or older torn by a kill). Recovery cannot replay it and it may
|
|
@@ -164,22 +165,31 @@ export function classifyEpisodeFile(
|
|
|
164
165
|
*
|
|
165
166
|
* `stale` is the number doctor may recommend cleanup for; `inFlight` is reported as a
|
|
166
167
|
* detail rather than a warning, because a file that is supposed to exist right now is not
|
|
167
|
-
* a fault.
|
|
168
|
-
*
|
|
169
|
-
* there is no age gate here for it to mirror. `unfinishedSwaps` are backup dirs with a readable
|
|
168
|
+
* a fault. Update residue has no age gate: cleanup guards it by install.lock, and so does this
|
|
169
|
+
* count, through `installLockHeld`. `unfinishedSwaps` are backup dirs with a readable
|
|
170
170
|
* journal and no resolved marker: cleanup finishes those swaps rather than removing them, and
|
|
171
171
|
* `unreadableJournals` it leaves (classifyUpdateResidue).
|
|
172
172
|
*
|
|
173
|
-
* @param {{codeDir: string, runtimeDir: string, legacyDir?: string,
|
|
173
|
+
* @param {{codeDir: string, runtimeDir: string, legacyDir?: string, installLockHeld?: boolean,
|
|
174
|
+
* now?: number, episodeAgeMs?: number}} args
|
|
174
175
|
* codeDir is where update residue lives; runtimeDir is where episode files live. legacyDir is a
|
|
175
176
|
* data dir CLAUDE_MEM_DIR keeps apart from the code dir: update residue there was left by an
|
|
176
177
|
* updater older than v2.90.0, which swapped into it, so all of it is stale and none of it is
|
|
177
|
-
* replayed. (It holds no journal either: journals came in v3.57.0.)
|
|
178
|
+
* replayed. (It holds no journal either: journals came in v3.57.0.) installLockHeld: some
|
|
179
|
+
* installer holds install.lock, so update residue is not counted at all, as cleanup skips it
|
|
180
|
+
* (D#297).
|
|
178
181
|
* @returns {{stale: number, inFlight: number, unfinishedSwaps: number, unreadableJournals: number,
|
|
179
182
|
* notChecked: Array<{dir: string, code: string}>}} A directory that exists but cannot be listed
|
|
180
183
|
* goes in notChecked, never counted as empty, and the others are still counted.
|
|
181
184
|
*/
|
|
182
|
-
export function scanStaleTempFiles({
|
|
185
|
+
export function scanStaleTempFiles({
|
|
186
|
+
codeDir,
|
|
187
|
+
runtimeDir,
|
|
188
|
+
legacyDir,
|
|
189
|
+
installLockHeld = false,
|
|
190
|
+
now = Date.now(),
|
|
191
|
+
episodeAgeMs,
|
|
192
|
+
}) {
|
|
183
193
|
let stale = 0;
|
|
184
194
|
let inFlight = 0;
|
|
185
195
|
let unfinishedSwaps = 0;
|
|
@@ -194,13 +204,13 @@ export function scanStaleTempFiles({ codeDir, runtimeDir, legacyDir, now = Date.
|
|
|
194
204
|
}
|
|
195
205
|
};
|
|
196
206
|
|
|
197
|
-
for (const f of list(codeDir)) {
|
|
207
|
+
for (const f of installLockHeld ? [] : list(codeDir)) {
|
|
198
208
|
const kind = classifyUpdateResidue(codeDir, f);
|
|
199
209
|
if (kind === 'unfinished-swap') unfinishedSwaps++;
|
|
200
210
|
else if (kind === 'unreadable-journal') unreadableJournals++;
|
|
201
211
|
else if (kind === 'stale') stale++;
|
|
202
212
|
}
|
|
203
|
-
if (legacyDir) {
|
|
213
|
+
if (legacyDir && !installLockHeld) {
|
|
204
214
|
for (const f of list(legacyDir)) if (isUpdateResidue(f)) stale++;
|
|
205
215
|
}
|
|
206
216
|
|
package/lib/proc-lock.mjs
CHANGED
|
@@ -10,21 +10,30 @@
|
|
|
10
10
|
// a real cross-process gate.
|
|
11
11
|
//
|
|
12
12
|
// Semantics: acquireLock() atomically creates the lock file with O_EXCL. If it
|
|
13
|
-
// already exists it is stolen only when STALE
|
|
14
|
-
//
|
|
15
|
-
// acquire returns null and the caller no-ops (someone else is
|
|
16
|
-
// write). Release unlinks the file. Crash-safe: a crashed holder's
|
|
17
|
-
//
|
|
13
|
+
// already exists it is stolen only when STALE: the recorded pid is provably dead on
|
|
14
|
+
// this host, or the lock is older than LIVE_HOLDER_MAX_MS (staleMs when it records no
|
|
15
|
+
// pid). A live holder → acquire returns null and the caller no-ops (someone else is
|
|
16
|
+
// already doing the write). Release unlinks the file. Crash-safe: a crashed holder's
|
|
17
|
+
// pid is gone, so the next session reclaims its lock at once.
|
|
18
18
|
|
|
19
|
-
import { writeFileSync, readFileSync, unlinkSync, mkdirSync, renameSync, linkSync } from 'node:fs';
|
|
19
|
+
import { writeFileSync, readFileSync, unlinkSync, mkdirSync, renameSync, linkSync, lstatSync } from 'node:fs';
|
|
20
20
|
import { dirname } from 'node:path';
|
|
21
21
|
import { workerDirGone } from './worker-data-dir.mjs';
|
|
22
22
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
23
|
+
// The age bound for a lock whose holder cannot be checked: it records no pid. A holder that
|
|
24
|
+
// records one is reclaimed at once when the pid is gone, and otherwise waits for
|
|
25
|
+
// LIVE_HOLDER_MAX_MS below.
|
|
26
26
|
const DEFAULT_STALE_MS = 5 * 60 * 1000;
|
|
27
27
|
|
|
28
|
+
// Age past which a lock whose recorded pid is ALIVE is reclaimed anyway: by then that pid is
|
|
29
|
+
// taken to be recycled onto an unrelated process, or to belong to another host on a shared
|
|
30
|
+
// homedir. It is not a bound on the critical section, which has none: an update holds
|
|
31
|
+
// install.lock through npm install and a smoke gate that can rebuild the binding twice and from
|
|
32
|
+
// source (about 12 min in all), and a direct `install` runs npm with no timeout. staleMs used to
|
|
33
|
+
// apply to a live holder too, so the next entry took the lock after 5 min and replayed the
|
|
34
|
+
// running swap's journal (D#294). hook.mjs's lock sweeper spares a live holder for the same span.
|
|
35
|
+
export const LIVE_HOLDER_MAX_MS = 60 * 60 * 1000;
|
|
36
|
+
|
|
28
37
|
function pidAlive(pid) {
|
|
29
38
|
if (typeof pid !== 'number' || pid <= 0) return false;
|
|
30
39
|
try {
|
|
@@ -40,11 +49,12 @@ function pidAlive(pid) {
|
|
|
40
49
|
function isStale(lockPath, staleMs, now) {
|
|
41
50
|
try {
|
|
42
51
|
const { pid, ts } = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
return
|
|
52
|
+
const aged = (bound) => typeof ts === 'number' && now() - ts > bound;
|
|
53
|
+
if (typeof pid !== 'number') return aged(staleMs);
|
|
54
|
+
// Same-host fast reclaim: holder pid is gone. Cross-host (shared homedir) the pid is
|
|
55
|
+
// meaningless, and a live-looking one is reclaimed by age below.
|
|
56
|
+
if (!pidAlive(pid)) return true;
|
|
57
|
+
return aged(Math.max(staleMs, LIVE_HOLDER_MAX_MS));
|
|
48
58
|
} catch {
|
|
49
59
|
return true; // unparseable / unreadable lock → treat as stale and reclaim
|
|
50
60
|
}
|
|
@@ -115,7 +125,8 @@ function makeRelease(lockPath, payload) {
|
|
|
115
125
|
* Try to acquire an advisory lock. Non-blocking.
|
|
116
126
|
* @param {string} lockPath Absolute path to the lock file.
|
|
117
127
|
* @param {object} [opts]
|
|
118
|
-
* @param {number} [opts.staleMs] Age after which a
|
|
128
|
+
* @param {number} [opts.staleMs] Age after which a lock that records no pid is stolen. A live pid
|
|
129
|
+
* holds it until LIVE_HOLDER_MAX_MS, or staleMs if that is longer.
|
|
119
130
|
* @param {() => number} [opts.now] Clock injection seam (tests).
|
|
120
131
|
* @returns {(() => void)|null} A release() fn, or null if a live peer holds it, the lock file
|
|
121
132
|
* could not be created, or this is a background worker whose lock directory was removed.
|
|
@@ -201,6 +212,29 @@ export function acquireLock(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.n
|
|
|
201
212
|
}
|
|
202
213
|
}
|
|
203
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Whether acquireLock would refuse `lockPath` now because a peer holds it. Read-only: it creates
|
|
217
|
+
* nothing, so a command that must not write (`cleanup --dry-run`) or only reports (`doctor`) can
|
|
218
|
+
* ask without taking the lock (D#297). A lock file that is there but cannot be read is held:
|
|
219
|
+
* acquireLock cannot steal what it cannot read, so it refuses (pre-ship review P3-1). One whose
|
|
220
|
+
* directory cannot be looked into is not known to be held by anyone; the caller names that
|
|
221
|
+
* directory on its own.
|
|
222
|
+
* @returns {boolean}
|
|
223
|
+
*/
|
|
224
|
+
export function lockHeld(lockPath, { staleMs = DEFAULT_STALE_MS, now = Date.now } = {}) {
|
|
225
|
+
try {
|
|
226
|
+
lstatSync(lockPath);
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
readFileSync(lockPath);
|
|
232
|
+
} catch (e) {
|
|
233
|
+
return e.code !== 'ENOENT'; // released between the two calls: acquireLock would take it
|
|
234
|
+
}
|
|
235
|
+
return !isStale(lockPath, staleMs, now);
|
|
236
|
+
}
|
|
237
|
+
|
|
204
238
|
/**
|
|
205
239
|
* Run `fn` while holding the lock; release in a finally. No-op if not acquired.
|
|
206
240
|
* @returns {{acquired: boolean, result?: any}}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// lib/wal-open-blocked.mjs — a closed WAL store in a data dir this user cannot write (D#287, D#296).
|
|
2
|
+
//
|
|
3
|
+
// The store is WAL (schema.mjs), and even a read-only open has to create the -wal and -shm files
|
|
4
|
+
// beside the DB. In a directory SQLite cannot write, a store that was closed cleanly (so those
|
|
5
|
+
// files are gone) cannot be opened in place, and SQLite answers "attempt to write a readonly
|
|
6
|
+
// database", a message it uses for other faults too. doctor, status and every command that opens
|
|
7
|
+
// the DB through mem-cli say instead what is wrong and how to fix it, keyed on SQLite's code and the
|
|
8
|
+
// directory's own access error, never on the message.
|
|
9
|
+
//
|
|
10
|
+
// A leaf (node:fs and cli-path.mjs): mem-cli opens the DB on every command and must not load
|
|
11
|
+
// install.mjs to word one error.
|
|
12
|
+
import { accessSync, lstatSync, constants as fsConstants } from 'node:fs';
|
|
13
|
+
import { shellWord } from '../cli-path.mjs';
|
|
14
|
+
|
|
15
|
+
export const WAL_BLOCKED_WHY =
|
|
16
|
+
'SQLite cannot open a WAL database without creating its -wal/-shm files beside it';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The access error code for `dir` under `mode` (read, write and search by default), or null when
|
|
20
|
+
* it is usable or absent.
|
|
21
|
+
*/
|
|
22
|
+
export function dirAccessError(dir, mode = fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK) {
|
|
23
|
+
try {
|
|
24
|
+
accessSync(dir, mode);
|
|
25
|
+
return null;
|
|
26
|
+
} catch (e) {
|
|
27
|
+
return e.code === 'ENOENT' ? null : e.code || 'EACCES';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether an open of `dbPath` that failed with SQLite error `code` is that store, given its dir's
|
|
33
|
+
* access error. A dir the user cannot write (EACCES) gives SQLITE_READONLY_DIRECTORY. A read-only
|
|
34
|
+
* mount (EROFS) gives SQLITE_CANTOPEN "unable to open database file" instead, when the -shm file is
|
|
35
|
+
* missing and SQLite cannot create it (D#295, measured on a kernel read-only mount: a cleanly
|
|
36
|
+
* closed store, and one with a -wal but no -shm; with both files there it opens and reads).
|
|
37
|
+
* CANTOPEN has other causes, a missing database among them, so it counts only on a read-only
|
|
38
|
+
* mount, with the database there and no -shm beside it.
|
|
39
|
+
*/
|
|
40
|
+
export function walOpenBlocked(code, dirDenied, dbPath) {
|
|
41
|
+
if (!dirDenied) return false;
|
|
42
|
+
if (code === 'SQLITE_READONLY_DIRECTORY') return true;
|
|
43
|
+
return (
|
|
44
|
+
code === 'SQLITE_CANTOPEN' &&
|
|
45
|
+
dirDenied === 'EROFS' &&
|
|
46
|
+
Boolean(dbPath) &&
|
|
47
|
+
pathPresent(dbPath) &&
|
|
48
|
+
!pathPresent(`${dbPath}-shm`)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function pathPresent(p) {
|
|
53
|
+
try {
|
|
54
|
+
lstatSync(p);
|
|
55
|
+
return true;
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return e.code !== 'ENOENT';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The fix for a data dir this user cannot use, chosen by its access error. EROFS is the file
|
|
63
|
+
* system, not the mode bits: accessSync(W_OK) on a read-only mount fails with it, and chmod cannot
|
|
64
|
+
* help there (D#287).
|
|
65
|
+
*/
|
|
66
|
+
export const dataDirRemedy = (code, dir) =>
|
|
67
|
+
code === 'EROFS'
|
|
68
|
+
? `the file system holding ${shellWord(dir)} is mounted read-only — remount it read-write`
|
|
69
|
+
: `chmod u+rwx ${shellWord(dir)} (or chown it back to your user if a sudo run created it)`;
|
package/mem-cli.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// No MCP SDK or heavy deps — only imports schema.mjs and utils.mjs
|
|
4
4
|
|
|
5
5
|
import { ensureDbWithWalRecovery, refreshPlannerStats, DB_PATH, DB_DIR, CODE_DIR } from './schema.mjs';
|
|
6
|
+
import { dirAccessError, walOpenBlocked, dataDirRemedy, WAL_BLOCKED_WHY } from './lib/wal-open-blocked.mjs';
|
|
6
7
|
import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
|
|
7
8
|
import { isFtsCorruptionError, FTS_CORRUPTION_REMEDY } from './lib/db-unusable.mjs';
|
|
8
9
|
import {
|
|
@@ -3877,6 +3878,21 @@ async function runDispatch(argv) {
|
|
|
3877
3878
|
process.exitCode = 1;
|
|
3878
3879
|
return;
|
|
3879
3880
|
}
|
|
3881
|
+
// A closed WAL store in a data dir this user cannot write: name the cause and its fix rather
|
|
3882
|
+
// than relay "attempt to write a readonly database", or "unable to open database file" on a
|
|
3883
|
+
// read-only mount (D#296, D#295; doctor and status say the same).
|
|
3884
|
+
const dirDenied = /^SQLITE_(READONLY_DIRECTORY|CANTOPEN)$/.test(e.code ?? '')
|
|
3885
|
+
? dirAccessError(DB_DIR)
|
|
3886
|
+
: null;
|
|
3887
|
+
if (walOpenBlocked(e.code, dirDenied, DB_PATH)) {
|
|
3888
|
+
out(
|
|
3889
|
+
`[mem] Error: Cannot open database: ${WAL_BLOCKED_WHY}, and ${DB_DIR} cannot be written (${dirDenied})`,
|
|
3890
|
+
);
|
|
3891
|
+
out(`[mem] Fix: ${dataDirRemedy(dirDenied, DB_DIR)}`);
|
|
3892
|
+
out(`[mem] DB path: ${DB_PATH}`);
|
|
3893
|
+
process.exitCode = 1;
|
|
3894
|
+
return;
|
|
3895
|
+
}
|
|
3880
3896
|
out(`[mem] Error: Cannot open database: ${e.message}`);
|
|
3881
3897
|
out(`[mem] DB path: ${DB_PATH}`);
|
|
3882
3898
|
process.exitCode = 1;
|
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.2",
|
|
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.2",
|
|
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.2",
|
|
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",
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"adopt-cli.mjs",
|
|
68
68
|
"haiku-client.mjs",
|
|
69
69
|
"lib/activity.mjs",
|
|
70
|
+
"lib/wal-open-blocked.mjs",
|
|
70
71
|
"lib/bash-file-targets.mjs",
|
|
71
72
|
"lib/cli-flags.mjs",
|
|
72
73
|
"lib/git-state.mjs",
|
package/source-files.mjs
CHANGED
|
@@ -162,6 +162,9 @@ export const SOURCE_FILES = [
|
|
|
162
162
|
// + auto-update lock). Must ship or a partial install/update skips them.
|
|
163
163
|
'lib/proc-lock.mjs',
|
|
164
164
|
'lib/atomic-write.mjs',
|
|
165
|
+
// A closed WAL store in an unwritable data dir: one classification for doctor, status and
|
|
166
|
+
// mem-cli's DB open (D#296), so the CLI does not load install.mjs to word one error.
|
|
167
|
+
'lib/wal-open-blocked.mjs',
|
|
165
168
|
// Dynamically imported by scripts/launch.mjs BEFORE `npm install` runs, to answer
|
|
166
169
|
// EBADPLATFORM with both sides of the mismatch instead of a guessed cause (issue #28).
|
|
167
170
|
// The import is guarded, so omitting this file would not crash the launcher — it would
|