claude-mem-lite 6.25.0 → 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 +243 -37
- package/hook.mjs +8 -5
- package/install.mjs +393 -87
- package/lib/doctor-stale-temp.mjs +129 -16
- package/lib/proc-lock.mjs +49 -15
- package/lib/schema-skew.mjs +5 -2
- 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
|
@@ -828,11 +828,15 @@ export async function verifyReleaseAuthenticity(extractedDir, assets, publicKey
|
|
|
828
828
|
// updater that was killed mid-swap (it applies the same staleness bound).
|
|
829
829
|
const SWAP_MARKER = join(STATE_DIR, 'runtime', 'swap-in-progress'); // runtime-dir:stays-put — installation identity
|
|
830
830
|
// Intent journal, written INSIDE the backup dir before each rename. On a hard kill
|
|
831
|
-
// the backup dir survives (
|
|
831
|
+
// the backup dir survives (a normal exit deletes it, unless its journal cannot be
|
|
832
|
+
// removed, D#283) and this file says exactly
|
|
832
833
|
// which paths were in flight, so the next entry can finish the rollback at the right
|
|
833
834
|
// granularity — a bare directory walk cannot tell a nested relPath from a directory
|
|
834
835
|
// relPath like `node_modules`.
|
|
835
|
-
const SWAP_JOURNAL = '.swap-journal.json';
|
|
836
|
+
const SWAP_JOURNAL = '.swap-journal.json'; // doctor and cleanup spell it SWAP_JOURNAL_NAME (lib/doctor-stale-temp.mjs)
|
|
837
|
+
// Written into the backup dir when its swap is over (committed, or rolled back): never replay it
|
|
838
|
+
// (D#292 C). Doctor and cleanup spell it SWAP_RESOLVED_NAME.
|
|
839
|
+
const SWAP_RESOLVED = '.swap-resolved';
|
|
836
840
|
|
|
837
841
|
function markSwapStart() {
|
|
838
842
|
try {
|
|
@@ -856,19 +860,164 @@ function clearSwapMarker() {
|
|
|
856
860
|
// backup dir but nothing records it — recovery would then delete the backup dir with
|
|
857
861
|
// the only copy of that file inside it. rollbackInstall guards every entry with
|
|
858
862
|
// existsSync/force, so an intent that never happened is a harmless no-op.
|
|
863
|
+
//
|
|
864
|
+
// By temp file and rename, and it throws (D#289). writeFileSync truncates the journal before it
|
|
865
|
+
// writes it, so a kill or ENOSPC in between left an empty journal, which recovery read as "nothing
|
|
866
|
+
// moved" and then deleted the backups. A write error was swallowed, so the rename it was meant to
|
|
867
|
+
// record went ahead unrecorded. Now the previous journal stays whole until the new one replaces
|
|
868
|
+
// it, and a swap that cannot journal stops before the rename, through the rollback in the caller.
|
|
869
|
+
//
|
|
870
|
+
// Retried for a moment on EPERM/EBUSY/EACCES: the rename replaces a file just written, about twice
|
|
871
|
+
// per switched path, and on Windows a scanner holding that file makes a rename over it fail
|
|
872
|
+
// briefly. Each failure stopped the update and rolled it back, and a rollback is where a restore
|
|
873
|
+
// can fail (D#292 A). Bounded at about 0.7 s, then it throws as before.
|
|
874
|
+
const JOURNAL_RETRY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
859
875
|
function journalSwap(backupDir, backedUp, installed) {
|
|
876
|
+
const data = JSON.stringify({ backedUp, installed });
|
|
877
|
+
for (let attempt = 1; ; attempt++) {
|
|
878
|
+
try {
|
|
879
|
+
atomicWriteFileSync(join(backupDir, SWAP_JOURNAL), data);
|
|
880
|
+
return;
|
|
881
|
+
} catch (e) {
|
|
882
|
+
if (attempt >= 8 || !JOURNAL_RETRY_CODES.has(e.code)) throw e;
|
|
883
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * attempt);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Clean up after a swap that is over: committed, or rolled back. Never throws, because a cleanup
|
|
889
|
+
// that throws reaches the caller's catch, which rolls the swap back again (D#283; EBUSY is
|
|
890
|
+
// ordinary on Windows and `force` does not suppress it).
|
|
891
|
+
//
|
|
892
|
+
// The dir is marked resolved first (D#292 C). A journal left behind (it could not be removed, or a
|
|
893
|
+
// kill came first) was replayed by the next recovery: over a committed swap that put the previous
|
|
894
|
+
// release back, and over a rolled-back one whose journal outlived a later swap it deleted a path
|
|
895
|
+
// that swap had installed. Recovery, install, cleanup and doctor leave a resolved dir's journal
|
|
896
|
+
// alone. The marker is a new empty file, so it needs no access to the journal a failed removal
|
|
897
|
+
// could not get. With the marker written, a journal that stays is inert and the backups go anyway,
|
|
898
|
+
// child by child around the journal and the marker: a recursive removal of the dir takes children
|
|
899
|
+
// in directory order and stops at the first failure, so it could take the marker and then stop at
|
|
900
|
+
// the journal (v6.25.1 delta review). If the marker cannot be written either, the journal still
|
|
901
|
+
// goes before the backups, and if it stays, so do the backups, complete, so that a replay restores
|
|
902
|
+
// one whole release.
|
|
903
|
+
function discardBackupDir(backupDir) {
|
|
904
|
+
let resolved = false;
|
|
905
|
+
try {
|
|
906
|
+
writeFileSync(join(backupDir, SWAP_RESOLVED), '');
|
|
907
|
+
resolved = true;
|
|
908
|
+
} catch (e) {
|
|
909
|
+
debugCatch(e, 'mark-swap-resolved');
|
|
910
|
+
}
|
|
911
|
+
try {
|
|
912
|
+
rmSync(join(backupDir, SWAP_JOURNAL), { force: true });
|
|
913
|
+
} catch (e) {
|
|
914
|
+
debugCatch(e, 'discard-swap-journal');
|
|
915
|
+
if (resolved) removeBackupsBeside(backupDir);
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
try {
|
|
919
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
920
|
+
} catch (e) {
|
|
921
|
+
debugCatch(e, 'discard-backup-dir');
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function removeBackupsBeside(backupDir) {
|
|
926
|
+
let names = [];
|
|
860
927
|
try {
|
|
861
|
-
|
|
928
|
+
names = readdirSync(backupDir);
|
|
862
929
|
} catch (e) {
|
|
863
|
-
debugCatch(e, '
|
|
930
|
+
debugCatch(e, 'discard-backups-list');
|
|
931
|
+
}
|
|
932
|
+
for (const name of names) {
|
|
933
|
+
if (name === SWAP_JOURNAL || name === SWAP_RESOLVED) continue;
|
|
934
|
+
try {
|
|
935
|
+
rmSync(join(backupDir, name), { recursive: true, force: true });
|
|
936
|
+
} catch (e) {
|
|
937
|
+
debugCatch(e, 'discard-backup');
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Mark resolved the backup dirs a swap found left over and unfinished (recovery leaves one whose
|
|
943
|
+
// journal it cannot read right now). Called once that swap has committed: it replaced every path
|
|
944
|
+
// it switches, so a later replay of an older journal, once it can be read, would only mix the
|
|
945
|
+
// releases back (v6.25.1 delta review). The same retirement install does after writing the tree.
|
|
946
|
+
function retireUnresolvedBackups(targetDir, names) {
|
|
947
|
+
for (const name of names) {
|
|
948
|
+
try {
|
|
949
|
+
writeFileSync(join(targetDir, name, SWAP_RESOLVED), '');
|
|
950
|
+
debugLog('WARN', 'hook-update', `Retired ${name}: the committed swap replaced what its journal names`);
|
|
951
|
+
} catch (e) {
|
|
952
|
+
debugCatch(e, 'retire-unresolved-backup');
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
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
|
+
|
|
967
|
+
// The `.update-backup-*` dirs in targetDir that carry no resolved marker.
|
|
968
|
+
function unresolvedBackups(targetDir) {
|
|
969
|
+
try {
|
|
970
|
+
return readdirSync(targetDir, { withFileTypes: true })
|
|
971
|
+
.filter((e) => e.isDirectory() && e.name.startsWith('.update-backup-'))
|
|
972
|
+
.map((e) => e.name)
|
|
973
|
+
.filter((name) => !existsSync(join(targetDir, name, SWAP_RESOLVED)));
|
|
974
|
+
} catch (e) {
|
|
975
|
+
debugCatch(e, 'list-unresolved-backups');
|
|
976
|
+
return [];
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// The backup dir is resolved before staging goes: removing a whole staging tree takes long enough
|
|
981
|
+
// for a kill to land in, and a kill there left a committed swap's journal to be replayed.
|
|
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
|
+
);
|
|
995
|
+
try {
|
|
996
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
997
|
+
} catch (e) {
|
|
998
|
+
debugCatch(e, 'discard-staging-dir');
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
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';
|
|
864
1011
|
}
|
|
865
1012
|
}
|
|
866
1013
|
|
|
867
1014
|
/**
|
|
868
1015
|
* Finish any swap a previous process was killed in the middle of, then clear its
|
|
869
1016
|
* residue. Called on every install entry, under the install lock, BEFORE a new
|
|
870
|
-
* staging/backup pair is created.
|
|
871
|
-
*
|
|
1017
|
+
* staging/backup pair is created; and by install.mjs, under the same lock, before
|
|
1018
|
+
* `install` (and so `repair`) writes the tree and from `cleanup` (D#289).
|
|
1019
|
+
* @returns {number} number of backup dirs processed, with a journal or without; one left in place
|
|
1020
|
+
* because its journal cannot be read is not counted
|
|
872
1021
|
*/
|
|
873
1022
|
export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
|
|
874
1023
|
let entries;
|
|
@@ -879,6 +1028,7 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
|
|
|
879
1028
|
}
|
|
880
1029
|
|
|
881
1030
|
let recovered = 0;
|
|
1031
|
+
const pending = [];
|
|
882
1032
|
for (const entry of entries) {
|
|
883
1033
|
if (!entry.isDirectory()) continue;
|
|
884
1034
|
const dir = join(targetDir, entry.name);
|
|
@@ -895,26 +1045,69 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
|
|
|
895
1045
|
}
|
|
896
1046
|
if (!entry.name.startsWith('.update-backup-')) continue;
|
|
897
1047
|
|
|
1048
|
+
// Its swap is over and only its cleanup failed: remove what is left, never replay it (D#292 C).
|
|
1049
|
+
if (existsSync(join(dir, SWAP_RESOLVED))) {
|
|
1050
|
+
discardBackupDir(dir);
|
|
1051
|
+
recovered++;
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
pending.push(entry.name);
|
|
1055
|
+
}
|
|
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);
|
|
1064
|
+
// No journal: the swap is over and its journal is gone (or it was killed before its first
|
|
1065
|
+
// rename), so there is nothing to replay and the dir is residue. A journal that is there but
|
|
1066
|
+
// cannot be read, or not as one (an in-place write of 6.25.0 or older torn by a kill), says
|
|
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;
|
|
1071
|
+
try {
|
|
1072
|
+
raw = readFileSync(join(dir, SWAP_JOURNAL), 'utf8');
|
|
1073
|
+
} catch (e) {
|
|
1074
|
+
if (e.code === 'ENOENT') {
|
|
1075
|
+
discardBackupDir(dir);
|
|
1076
|
+
recovered++;
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
debugCatch(e, 'recover-read-journal');
|
|
1080
|
+
break;
|
|
1081
|
+
}
|
|
898
1082
|
let journal;
|
|
899
1083
|
try {
|
|
900
|
-
journal = JSON.parse(
|
|
1084
|
+
journal = JSON.parse(raw);
|
|
901
1085
|
} catch {
|
|
902
1086
|
journal = null;
|
|
903
1087
|
}
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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;
|
|
912
1104
|
}
|
|
1105
|
+
discardBackupDir(dir);
|
|
913
1106
|
recovered++;
|
|
914
1107
|
debugLog(
|
|
915
1108
|
'WARN',
|
|
916
1109
|
'hook-update',
|
|
917
|
-
`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}`,
|
|
918
1111
|
);
|
|
919
1112
|
}
|
|
920
1113
|
return recovered;
|
|
@@ -923,21 +1116,33 @@ export function recoverInterruptedSwaps(targetDir = INSTALL_DIR) {
|
|
|
923
1116
|
// A rollback swaps a file SET back just as the install swapped it in, so it holds the same
|
|
924
1117
|
// marker for the same reason (D#239 g). After the MED-5 smoke gate the loop has already cleared
|
|
925
1118
|
// it; after a throw inside the loop it is still set, and this call takes it over.
|
|
1119
|
+
//
|
|
1120
|
+
// Safe to run twice over one journal (D#283). A backed-up path whose backup is gone holds the OLD
|
|
1121
|
+
// file already, restored by an earlier rollback or never moved out, so it is not deleted: a
|
|
1122
|
+
// second rollback used to delete every installed path, by then the only copy of the old install.
|
|
1123
|
+
// That holds for a journal this code wrote: its cleanup removes the journal before any backup, and
|
|
1124
|
+
// when the journal cannot be removed, the resolved marker keeps it from being replayed. A backup
|
|
1125
|
+
// dir 6.25.0 or older left half deleted beside its journal can still replay to a mix.
|
|
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).
|
|
926
1129
|
function rollbackInstall(installed, backedUp, backupDir, targetDir) {
|
|
927
1130
|
markSwapStart();
|
|
928
1131
|
try {
|
|
929
|
-
|
|
1132
|
+
const restored = new Set(backedUp.filter((relPath) => !present(join(backupDir, relPath))));
|
|
1133
|
+
for (const relPath of [...installed].reverse()) {
|
|
1134
|
+
if (restored.has(relPath)) continue;
|
|
930
1135
|
try {
|
|
931
1136
|
rmSync(join(targetDir, relPath), { recursive: true, force: true });
|
|
932
1137
|
} catch {
|
|
933
1138
|
/* best-effort */
|
|
934
1139
|
}
|
|
935
1140
|
}
|
|
936
|
-
for (const relPath of backedUp.reverse()) {
|
|
1141
|
+
for (const relPath of [...backedUp].reverse()) {
|
|
937
1142
|
const backupPath = join(backupDir, relPath);
|
|
938
1143
|
const targetPath = join(targetDir, relPath);
|
|
939
1144
|
try {
|
|
940
|
-
if (
|
|
1145
|
+
if (present(backupPath)) {
|
|
941
1146
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
942
1147
|
renameSync(backupPath, targetPath);
|
|
943
1148
|
}
|
|
@@ -948,6 +1153,7 @@ function rollbackInstall(installed, backedUp, backupDir, targetDir) {
|
|
|
948
1153
|
} finally {
|
|
949
1154
|
clearSwapMarker();
|
|
950
1155
|
}
|
|
1156
|
+
return backedUp.filter((relPath) => present(join(backupDir, relPath)));
|
|
951
1157
|
}
|
|
952
1158
|
|
|
953
1159
|
// MED-5 post-install health gate: load-test the freshly-switched code in a SEPARATE
|
|
@@ -1046,9 +1252,11 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1046
1252
|
}
|
|
1047
1253
|
const ts = `${Date.now()}-${process.pid}`;
|
|
1048
1254
|
const stagingDir = join(targetDir, `.update-staging-${ts}`);
|
|
1049
|
-
|
|
1255
|
+
// Named once the seq is known, below; until then nothing has been written into it.
|
|
1256
|
+
let backupDir = join(targetDir, `.update-backup-${ts}`);
|
|
1050
1257
|
const backedUp = [];
|
|
1051
1258
|
const installed = [];
|
|
1259
|
+
let committed = false;
|
|
1052
1260
|
|
|
1053
1261
|
const manifest = await loadReleaseManifest(sourceDir);
|
|
1054
1262
|
const switchablePaths = buildSwitchablePaths(manifest.SOURCE_FILES);
|
|
@@ -1058,6 +1266,11 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1058
1266
|
// one — otherwise this install stacks on top of a mixed-version tree and its
|
|
1059
1267
|
// own backup can no longer restore a coherent state.
|
|
1060
1268
|
recoverInterruptedSwaps(targetDir);
|
|
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).
|
|
1272
|
+
const leftUnresolved = unresolvedBackups(targetDir);
|
|
1273
|
+
backupDir = join(targetDir, `.update-backup-s${nextSwapSeq(leftUnresolved)}-${ts}`);
|
|
1061
1274
|
|
|
1062
1275
|
mkdirSync(stagingDir, { recursive: true });
|
|
1063
1276
|
mkdirSync(backupDir, { recursive: true });
|
|
@@ -1086,7 +1299,7 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1086
1299
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
1087
1300
|
mkdirSync(dirname(backupPath), { recursive: true });
|
|
1088
1301
|
|
|
1089
|
-
if (
|
|
1302
|
+
if (present(targetPath)) {
|
|
1090
1303
|
backedUp.push(relPath);
|
|
1091
1304
|
journalSwap(backupDir, backedUp, installed);
|
|
1092
1305
|
renameSync(targetPath, backupPath);
|
|
@@ -1105,14 +1318,14 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1105
1318
|
// If it can't, restore the backup and report failure — the running (old) version
|
|
1106
1319
|
// keeps working rather than leaving a broken install with no way back.
|
|
1107
1320
|
if (!opts.skipSmoke && !smokeInstalledRelease(targetDir)) {
|
|
1108
|
-
rollbackInstall(installed, backedUp, backupDir, targetDir);
|
|
1109
|
-
rmSync(stagingDir, { recursive: true, force: true });
|
|
1110
|
-
rmSync(backupDir, { recursive: true, force: true });
|
|
1321
|
+
discardSwapDirs(stagingDir, backupDir, rollbackInstall(installed, backedUp, backupDir, targetDir));
|
|
1111
1322
|
return false;
|
|
1112
1323
|
}
|
|
1113
1324
|
|
|
1114
|
-
|
|
1115
|
-
|
|
1325
|
+
// Committed: from here on the catch below must not roll back (D#283).
|
|
1326
|
+
committed = true;
|
|
1327
|
+
retireUnresolvedBackups(targetDir, leftUnresolved);
|
|
1328
|
+
discardSwapDirs(stagingDir, backupDir);
|
|
1116
1329
|
|
|
1117
1330
|
// Post-update migration: reconcile settings.json against the release we just
|
|
1118
1331
|
// swapped in. `configureHooks()` already strips stale mem entries — but its only
|
|
@@ -1198,17 +1411,10 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
|
|
|
1198
1411
|
return true;
|
|
1199
1412
|
} catch (err) {
|
|
1200
1413
|
debugCatch(err, 'installExtractedRelease');
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
/* best-effort */
|
|
1206
|
-
}
|
|
1207
|
-
try {
|
|
1208
|
-
rmSync(backupDir, { recursive: true, force: true });
|
|
1209
|
-
} catch {
|
|
1210
|
-
/* best-effort */
|
|
1211
|
-
}
|
|
1414
|
+
// Past the commit point the backups are gone and the new release IS the install: a rollback
|
|
1415
|
+
// would delete the paths it added and restore nothing (D#283).
|
|
1416
|
+
if (committed) return true;
|
|
1417
|
+
discardSwapDirs(stagingDir, backupDir, rollbackInstall(installed, backedUp, backupDir, targetDir));
|
|
1212
1418
|
return false;
|
|
1213
1419
|
} finally {
|
|
1214
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 {
|