memory-eternal 0.10.33 → 0.10.35

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.
@@ -45,6 +45,32 @@ export function recordReplaceBlocked(env = process.env, { error = '', lockPath =
45
45
  try { writeResidentState(env, data); return { ok: true, recordedAt: at, firstTime } } catch { return { ok: false, recordedAt: at, firstTime } }
46
46
  }
47
47
 
48
+ /**
49
+ * 记下「常驻实例由**宿主**托管」(issue #30 ④ 的落地):锁不可写时看门狗路径被永久拒绝,
50
+ * 于是宿主自己停掉旧实例、在原端口拉起新的并保活 —— 这条状态让面板/接口知道「虽然锁里没登记,
51
+ * 但实例是有人管的」,而不是显示成「未托管 / 孤儿」。
52
+ */
53
+ export function recordHostManaged(env = process.env, { port = 0, version = '', served = '', reason = '' } = {}) {
54
+ const at = Date.now()
55
+ const data = { ...readResidentState(env), hostManaged: { at, port: Number(port) || 0, version: String(version || ''), served: String(served || ''), reason: String(reason || '').slice(0, 300), count: (Number((readResidentState(env).hostManaged || {}).count) || 0) + 1 } }
56
+ try { writeResidentState(env, data); return { ok: true, at } } catch { return { ok: false, at } }
57
+ }
58
+
59
+ /** 宿主托管状态(默认 24h 内算有效 —— 宿主进程一重启,这条就该被重新确认)。 */
60
+ export function hostManagedInfo(env = process.env, { freshMs = 24 * 3600 * 1000 } = {}) {
61
+ const h = readResidentState(env).hostManaged
62
+ if (!h) return null
63
+ return { ...h, fresh: Date.now() - Number(h.at || 0) < freshMs }
64
+ }
65
+
66
+ /** 看门狗重新登记(或用户手工接管)→ 清掉宿主托管状态。 */
67
+ export function clearHostManaged(env = process.env) {
68
+ const cur = readResidentState(env)
69
+ if (!cur.hostManaged) return false
70
+ delete cur.hostManaged
71
+ try { writeResidentState(env, cur); return true } catch { return false }
72
+ }
73
+
48
74
  /** 环境修好(锁可写了)→ 清掉这条状态。 */
49
75
  export function clearReplaceBlocked(env = process.env) {
50
76
  const cur = readResidentState(env)
package/lib/watchdog.js CHANGED
@@ -154,6 +154,53 @@ export function releaseWatchdogLock({ env = process.env, pid = process.pid, keep
154
154
  * 只补本端口、且当前没有**活着的**同端口登记时才写,避免覆盖新实例的登记。
155
155
  * @returns {boolean} 是否真的写回
156
156
  */
157
+ /**
158
+ * 「接管助手」的单飞互斥(issue #30 第六轮实测):宿主 tick 与 CLI 会各自拉起一个 `--replace`,
159
+ * 两个助手谁都不写锁、还互相收对方的 web —— 现场更难复盘。用一个 claim 文件做互斥:
160
+ * 助手开工前独占创建;已存在且**持有者还活着 + 没过期**就退出(不抢)。
161
+ *
162
+ * 失败开放(fail-open):受限环境写不了 claim 时**照常进行**(不能让权限问题把接管彻底堵死),
163
+ * 但要留一条可查的理由。
164
+ */
165
+ export function replaceClaimPath(env = process.env) {
166
+ return path.join(path.dirname(watchdogLockPath(env)), 'memory-eternal-replace.claim')
167
+ }
168
+
169
+ /** 读当前 claim(不存在/损坏返回 null)。 */
170
+ export function readReplaceClaim(env = process.env) {
171
+ try {
172
+ const d = JSON.parse(fs.readFileSync(replaceClaimPath(env), 'utf8'))
173
+ if (!d || typeof d !== 'object' || !d.pid) return null
174
+ return d
175
+ } catch { return null }
176
+ }
177
+
178
+ /** 抢 claim:抢到 true;有人在跑则是 false(附持有者信息)。 */
179
+ export function claimReplaceSlot({ env = process.env, port = 0, pid = process.pid, staleMs = 60000, now = Date.now() } = {}) {
180
+ const file = replaceClaimPath(env)
181
+ const cur = readReplaceClaim(env)
182
+ if (cur) {
183
+ const fresh = now - Number(cur.at || 0) < Math.max(1000, Number(staleMs) || 60000)
184
+ if (fresh && Number(cur.pid) !== Number(pid) && isPidAlive(cur.pid)) {
185
+ return { claimed: false, holder: cur, reason: '已有接管助手在跑(pid ' + cur.pid + ',' + Math.round((now - Number(cur.at || now)) / 1000) + 's 前)' }
186
+ }
187
+ }
188
+ try {
189
+ fs.mkdirSync(path.dirname(file), { recursive: true })
190
+ fs.writeFileSync(file, JSON.stringify({ pid: Number(pid), port: Number(port) || 0, at: now }))
191
+ return { claimed: true, holder: cur || null, reason: cur ? '接管了陈旧 claim' : '抢到 claim' }
192
+ } catch (e) {
193
+ return { claimed: true, holder: cur || null, reason: 'claim 写不进去(' + String((e && e.message) || e) + ')→ 失败开放,继续接管' }
194
+ }
195
+ }
196
+
197
+ /** 释放 claim(只清自己那条;别人的不动)。 */
198
+ export function releaseReplaceClaim({ env = process.env, pid = process.pid } = {}) {
199
+ const cur = readReplaceClaim(env)
200
+ if (cur && Number(cur.pid) !== Number(pid)) return false
201
+ try { fs.unlinkSync(replaceClaimPath(env)); return true } catch { return false }
202
+ }
203
+
157
204
  export function restoreWatchdogLock({ env = process.env, port, watchdogs = [] } = {}) {
158
205
  const p = Number(port) || 0
159
206
  const want = (Array.isArray(watchdogs) ? watchdogs : []).filter((w) => Number(w.port) === p)
@@ -782,6 +829,19 @@ export async function restartResident({
782
829
  }
783
830
  }
784
831
  }
832
+ // 单飞互斥(issue #30 第六轮实测):接管还在进行中时**不要再拉第二个助手** ——
833
+ // 宿主 tick 与 CLI 同时来的时候,两个助手会互相收对方的 web,现场反而更难复盘。
834
+ if (!deps.ignoreClaim) {
835
+ try {
836
+ const inflight = readReplaceClaim(env)
837
+ if (inflight && Number(inflight.pid) !== process.pid && isPidAlive(inflight.pid) && Date.now() - Number(inflight.at || 0) < 60000) {
838
+ return {
839
+ ok: false, stage: 'in-flight', expect, served: '', waitedMs: 0, helperPid: 0, before, lockRestored: false, occupant: null,
840
+ error: '已有接管正在进行中(pid ' + inflight.pid + ',' + Math.round((Date.now() - Number(inflight.at || Date.now())) / 1000) + 's 前启动)→ 本次不再重复拉起助手;稍后可再试 dsh-memory restart --port ' + p,
841
+ }
842
+ }
843
+ } catch { /* 读不到 claim 就当没有 */ }
844
+ }
785
845
  let helperPid = 0
786
846
  try {
787
847
  const child = (deps.spawnHelper || spawnReplaceHelper)({ port: p, vaultRoot, interval, maxRestart })
@@ -1302,6 +1362,15 @@ if (process.argv[1] && /[\\/]watchdog\.js$/.test(process.argv[1])) {
1302
1362
  try { fs.writeSync(2, `[watchdog] ${msg}\n`) } catch { log(msg) }
1303
1363
  process.exitCode = 0
1304
1364
  } else if (argv.includes('--replace')) {
1365
+ // 单飞互斥(issue #30 第六轮实测:宿主 tick 与 CLI 各拉一个助手互相踩):
1366
+ // 抢不到 claim 就**直接退出**,把接管让给已经在跑的那个;抢到就在退出时释放。
1367
+ const slotClaim = claimReplaceSlot({ port })
1368
+ if (!slotClaim.claimed) {
1369
+ log('跳过本次接管:' + slotClaim.reason + '(避免两个助手互相收对方的 web)')
1370
+ process.exitCode = 0
1371
+ } else {
1372
+ if (slotClaim.holder) log(slotClaim.reason)
1373
+ process.on('exit', () => { try { releaseReplaceClaim() } catch { /* 无妨 */ } })
1305
1374
  // 版本漂移自愈:先收掉旧常驻实例,再作为新一代看门狗接管(见 clearResidentForTakeover 的说明)。
1306
1375
  const cleared = await clearResidentForTakeover({ port })
1307
1376
  for (const w of cleared.warnings) log('⚠ ' + w)
@@ -1323,6 +1392,7 @@ if (process.argv[1] && /[\\/]watchdog\.js$/.test(process.argv[1])) {
1323
1392
  }
1324
1393
  await noteReplace('boot', summary)
1325
1394
  bootWatchdog()
1395
+ }
1326
1396
  } else {
1327
1397
  bootWatchdog()
1328
1398
  }
package/lib/web.js CHANGED
@@ -281,10 +281,50 @@ async function waitForOurWeb(port, timeoutMs = 6000, intervalMs = 500) {
281
281
  * 端口被非本项目进程占用时向后漂移(+1..+10)。
282
282
  * @returns {Promise<{url:string, port:number, spawned:boolean}>}
283
283
  */
284
- export async function ensureWebServer({ port = DEFAULT_WEB_PORT, vaultRoot = defaultVaultDir(), spawnEnv = {}, totalTimeoutMs = 15000, takeoverWaitMs = 6000 } = {}) {
284
+ /**
285
+ * 强制接管端口上的**自家旧 web**:收掉它、等端口空出来(issue #30 第四点:锁不可写的机器上
286
+ * 看门狗路径被永久拒绝,只能由**宿主**自己把常驻实例换掉 —— 宿主能写、也能 kill 自己人的进程)。
287
+ *
288
+ * 安全闸门:只对 `looksLikeOurWeb(command)` 为真的占用者下手;别人的进程一个都不许动。
289
+ *
290
+ * @returns {Promise<{took:boolean, pid:number, reason:string}>}
291
+ */
292
+ export async function takeoverOwnWeb(port, { findListener, kill, sleep, looksLike, timeoutMs = 4000 } = {}) {
293
+ const p = Number(port) || DEFAULT_WEB_PORT
294
+ const find = findListener || (await import('./watchdog.js')).findPortListener
295
+ const isOurs = looksLike || looksLikeOurWeb
296
+ const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
297
+ const killFn = kill || ((pid) => { try { process.kill(pid, 'SIGTERM'); return true } catch { return false } })
298
+ let occ = null
299
+ try { occ = await find(p) } catch { occ = null }
300
+ if (!occ || !occ.pid) return { took: false, pid: 0, reason: '端口上没有占用者' }
301
+ if (!isOurs(occ.command)) return { took: false, pid: Number(occ.pid), reason: '占用者不是本插件的 web(' + (occ.command || '命令行未知') + ')→ 不动它' }
302
+ let alive = true
303
+ try { alive = await isOurs(p) } catch { alive = true }
304
+ void alive
305
+ if (!killFn(occ.pid)) return { took: false, pid: Number(occ.pid), reason: '终止失败(可能无权限)' }
306
+ const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0)
307
+ while (Date.now() < deadline) {
308
+ await wait(200)
309
+ let left = null
310
+ try { left = await find(p) } catch { left = null }
311
+ if (!left || !left.pid) return { took: true, pid: Number(occ.pid), reason: '已收掉自家旧 web' }
312
+ }
313
+ return { took: false, pid: Number(occ.pid), reason: '已发出终止但端口仍被占用' }
314
+ }
315
+
316
+ export async function ensureWebServer({ port = DEFAULT_WEB_PORT, vaultRoot = defaultVaultDir(), spawnEnv = {}, totalTimeoutMs = 15000, takeoverWaitMs = 6000, forceTakeover = false, deps = {} } = {}) {
285
317
  // 探测可用端口:先找「已是我们的服务」的端口,再找空闲端口
286
318
  let target = port
287
319
  let found = null
320
+ // 强制接管(issue #30 ④):宿主自管的机器上,先把自家旧实例收掉,再在原端口起新的 ——
321
+ // 不这么做就会走到下面的「让位到 port+1」,于是既换不掉旧的、又多出一个实例。
322
+ if (forceTakeover) {
323
+ try {
324
+ const r = await takeoverOwnWeb(port, { findListener: deps.findListener, kill: deps.kill, sleep: deps.sleep, timeoutMs: Math.max(1500, Math.min(5000, totalTimeoutMs)) })
325
+ process.stderr.write(`[memory-eternal] 强制接管端口 ${port}:${r.reason}${r.pid ? '(pid ' + r.pid + ')' : ''}\n`)
326
+ } catch (e) { process.stderr.write(`[memory-eternal] 强制接管失败:${String((e && e.message) || e)}\n`) }
327
+ }
288
328
  for (let p = port; p < port + 10; p++) {
289
329
  const alive = await probeWebServer(p)
290
330
  if (alive) { found = { url: `http://127.0.0.1:${p}`, port: p, spawned: false }; break }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.33",
3
+ "version": "0.10.35",
4
4
  "description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -64,7 +64,7 @@
64
64
  "prepublishOnly": "node build.mjs",
65
65
  "release": "node scripts/release.mjs",
66
66
  "release:dry": "node scripts/release.mjs --dry-run",
67
- "test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/ssr.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs && node tests/issue-fixes-2.test.mjs && node tests/issue-fixes-3.test.mjs && node tests/watchdog-orphans.test.mjs && node tests/watchdog-restart.test.mjs && node tests/readme-i18n.test.mjs && node tests/issue-fixes-4.test.mjs && node tests/host-drain.test.mjs && node tests/e2e-host-drain.mjs && node tests/issue-fixes-5.test.mjs && node tests/issue-fixes-6.test.mjs && node tests/capture-json-repair.test.mjs && node tests/csrf-origin.test.mjs && node tests/watchdog-orphan-web.test.mjs && node tests/config-validate.test.mjs && node tests/issue-fixes-8.test.mjs && node tests/issue-fixes-9.test.mjs && node tests/release-verify.test.mjs && node tests/refine.test.mjs && node tests/db-restore.test.mjs && node tests/align.test.mjs && node tests/tags-normalize.test.mjs && node tests/resident-state.test.mjs && node tests/llm-route.test.mjs"
67
+ "test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/ssr.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs && node tests/issue-fixes-2.test.mjs && node tests/issue-fixes-3.test.mjs && node tests/watchdog-orphans.test.mjs && node tests/watchdog-restart.test.mjs && node tests/readme-i18n.test.mjs && node tests/issue-fixes-4.test.mjs && node tests/host-drain.test.mjs && node tests/e2e-host-drain.mjs && node tests/issue-fixes-5.test.mjs && node tests/issue-fixes-6.test.mjs && node tests/capture-json-repair.test.mjs && node tests/csrf-origin.test.mjs && node tests/watchdog-orphan-web.test.mjs && node tests/config-validate.test.mjs && node tests/issue-fixes-8.test.mjs && node tests/issue-fixes-9.test.mjs && node tests/release-verify.test.mjs && node tests/refine.test.mjs && node tests/db-restore.test.mjs && node tests/align.test.mjs && node tests/tags-normalize.test.mjs && node tests/resident-state.test.mjs && node tests/llm-route.test.mjs && node tests/host-managed.test.mjs && node tests/replace-claim.test.mjs"
68
68
  },
69
69
  "dependencies": {
70
70
  "@deepseek-ai/schemastery": "^3.18.1"