memory-eternal 0.10.28 → 0.10.31

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/lib/vault.js CHANGED
@@ -184,7 +184,7 @@ function rowToCard(row) {
184
184
  path: row.path,
185
185
  kind: row.kind,
186
186
  title: row.title,
187
- tags: JSON.parse(row.tags || '[]'),
187
+ tags: normalizeTags(row.tags),
188
188
  summary: row.summary || '',
189
189
  created: row.created_at,
190
190
  updated: row.updated_at,
@@ -199,7 +199,7 @@ function rowToCard(row) {
199
199
 
200
200
  /** 将 SQL 行还原为完整 Markdown 文本(frontmatter + body)。 */
201
201
  function rowToMarkdown(row) {
202
- const tags = JSON.parse(row.tags || '[]')
202
+ const tags = normalizeTags(row.tags)
203
203
  const lines = [
204
204
  '---',
205
205
  `formatVersion: 1`,
@@ -558,9 +558,43 @@ export async function setCardStatus(root, rel, status, { changedBy = 'system', r
558
558
  * `dedupAgainst` 传入候选集合时,去重只跟**这些**卡比(导入备份用:基线取导入前的快照,
559
559
  * 让同一批里互相近似的卡不会互吞)。
560
560
  */
561
+ /**
562
+ * 把各种形态的 tags 归一化成字符串数组(issue #41 的根因)。
563
+ *
564
+ * 现场:调用方通过 /write 传了**字符串**(`"[\"a\",\"b\"]"`),而 writeCard 直接 `JSON.stringify(tags)`
565
+ * → 落库变成「一个字符串的 JSON 表示」(双重编码)→ 读出来仍是字符串 → 前端 `tags.slice(...).map` 抛错
566
+ * → **整页黑屏**(React 根组件挂载失败)。自动沉淀路径本来就有 Array.isArray 检查,只有 /write 等直连入口漏了。
567
+ * 这里做成唯一入口:数组 / JSON 字符串 / 逗号串 / 双重编码的旧数据,全都收敛成数组。
568
+ */
569
+ export function normalizeTags(tags) {
570
+ let v = tags
571
+ if (typeof v === 'string') {
572
+ const s = v.trim()
573
+ if (!s) return []
574
+ // 最多剥两层:正常 JSON(["a","b"])+ 双重编码("[\"a\",\"b\"]" —— #41 的现场,
575
+ // 它以引号开头,所以只判断 startsWith('[') 会漏掉)。剥不动就按逗号串处理。
576
+ let cur = s
577
+ for (let i = 0; i < 2; i++) {
578
+ if (typeof cur !== 'string') break
579
+ const t = cur.trim()
580
+ if (!(t.startsWith('[') || t.startsWith('"'))) break
581
+ try { cur = JSON.parse(t) } catch { break }
582
+ }
583
+ v = typeof cur === 'string' ? cur.split(',') : cur
584
+ }
585
+ if (!Array.isArray(v)) return []
586
+ return v
587
+ .map((t) => String(t == null ? '' : t).trim())
588
+ .filter(Boolean)
589
+ .map((t) => t.slice(0, 30))
590
+ .slice(0, 20)
591
+ }
592
+
561
593
  export async function writeCard(root, { kind, title, tags = [], body, source = '', status = 'pending', submittedBy = '', severity = 'info', reason = '', deletedAt = '' }, { threshold = 0.62, dedup = true, dedupAgainst = null } = {}) {
562
594
  const db = getDb(root)
563
595
  const kindRoot = KIND_ROOTS[kind] || KIND_ROOTS.knowledge
596
+ // 唯一归一化入口:任何调用方(/write、MCP、脚本、自动沉淀)传字符串都不会再双重编码
597
+ tags = normalizeTags(tags)
564
598
 
565
599
  // 审核守卫:数据库层强制,调用方传的 status 仅作 fallback
566
600
  const enforcedStatus = enforceAudit(root, kind, submittedBy, status)
@@ -634,10 +668,10 @@ export async function mergeCards(root, paths) {
634
668
  if (rows.length < 2) return { ok: false, error: '读取失败' }
635
669
 
636
670
  const first = rows[0]
637
- const tags = new Set(JSON.parse(first.tags || '[]'))
671
+ const tags = new Set(normalizeTags(first.tags))
638
672
  let combined = ''
639
673
  rows.forEach((row, i) => {
640
- const rowTags = JSON.parse(row.tags || '[]')
674
+ const rowTags = normalizeTags(row.tags)
641
675
  rowTags.forEach((t) => tags.add(t))
642
676
  combined += (i ? '\n\n---\n\n' : '') + row.body.trim()
643
677
  })
@@ -784,7 +818,7 @@ export async function graph(root) {
784
818
  const bodyRows = db.prepare('SELECT path, body FROM cards').all()
785
819
  const bodyMap = new Map(bodyRows.map((r) => [r.path, r.body]))
786
820
  const cards = rows.map((r) => ({
787
- path: r.path, kind: r.kind, title: r.title, tags: JSON.parse(r.tags || '[]'),
821
+ path: r.path, kind: r.kind, title: r.title, tags: normalizeTags(r.tags),
788
822
  summary: r.summary || '', updated: r.updated_at, mtime: new Date(r.updated_at).getTime(), status: r.status,
789
823
  }))
790
824
 
@@ -983,7 +1017,7 @@ export async function stats(root) {
983
1017
  const weekRow = db.prepare('SELECT COUNT(*) as cnt FROM cards WHERE updated_at >= ?').get(weekAgo)
984
1018
  const tagRows = db.prepare('SELECT tags FROM cards').all()
985
1019
  const tagSet = new Set()
986
- for (const r of tagRows) { for (const t of JSON.parse(r.tags || '[]')) tagSet.add(t) }
1020
+ for (const r of tagRows) { for (const t of normalizeTags(r.tags)) tagSet.add(t) }
987
1021
 
988
1022
  const todayCardsRows = db.prepare('SELECT * FROM cards WHERE updated_at >= ? ORDER BY updated_at DESC LIMIT 30').all(dayAgo)
989
1023
  const todayCards = todayCardsRows.map((row) => ({ path: row.path, title: row.title, kind: row.kind, updated: row.updated_at, summary: (row.summary || '').slice(0, 80) }))
package/lib/watchdog.js CHANGED
@@ -108,18 +108,23 @@ export function probeLockWritable(env = process.env) {
108
108
  * 尝试取得看门狗锁(按端口分槽)。
109
109
  * @returns {{acquired:boolean, file:string, previous:object|null, reaped:number[]}} acquired=false 表示同端口已有活着的看门狗,调用方应直接退出。
110
110
  */
111
- export function acquireWatchdogLock({ env = process.env, port, vault = '', pid = process.pid, pkgVersion = currentPkgVersion() } = {}) {
111
+ export function acquireWatchdogLock({ env = process.env, port, vault = '', pid = process.pid, pkgVersion = currentPkgVersion(), force = false } = {}) {
112
112
  const file = watchdogLockPath(env)
113
113
  const cur = readWatchdogLock(env)
114
114
  const samePort = cur.watchdogs.filter((w) => Number(w.port) === Number(port) && w.pid !== pid)
115
- const alive = samePort.find((w) => isPidAlive(w.pid))
116
- if (alive) return { acquired: false, file, previous: alive, reaped: [] }
115
+ // endedAt 的槽是「已结束」的记录(releaseWatchdogLock 留下的痕迹,见 #30):**绝不能挡住新实例**
116
+ // —— pid 可能已被复用、那个进程还活着,但那条记录明确表示它已经交出这把锁。
117
+ const alive = samePort.find((w) => !w.endedAt && isPidAlive(w.pid))
118
+ // force:--replace 助手的语义就是「替换同端口那个」。不允许让位 —— 否则旧实例还活着时
119
+ // 新助手直接退出,而清场那一步可能根本没跑完(issue #30 实测:清场被十几秒的枚举拖住 →
120
+ // 助手退在写锁之前 → 锁永远为空 → verify 必然超时、restart 100% 失败)。
121
+ if (alive && !force) return { acquired: false, file, previous: alive, reaped: [] }
117
122
  // 清掉死掉的槽(含其它端口的陈旧记录),保留仍然存活的其它端口
118
123
  const reaped = []
119
124
  const kept = []
120
125
  for (const w of cur.watchdogs) {
121
126
  if (Number(w.port) === Number(port)) { reaped.push(w.pid); continue }
122
- if (isPidAlive(w.pid)) kept.push(w)
127
+ if (!w.endedAt && isPidAlive(w.pid)) kept.push(w)
123
128
  else reaped.push(w.pid)
124
129
  }
125
130
  const previous = samePort[0] || (reaped.length ? { pid: reaped[0] } : null)
@@ -128,13 +133,38 @@ export function acquireWatchdogLock({ env = process.env, port, vault = '', pid =
128
133
  return { acquired: true, file, previous, reaped }
129
134
  }
130
135
 
131
- /** 释放锁(只清自己那一槽;没有其它槽就删文件)。 */
132
- export function releaseWatchdogLock({ env = process.env, pid = process.pid } = {}) {
136
+ /**
137
+ * 释放锁(只清自己那一槽)。
138
+ *
139
+ * issue #30 实测:旧实现在「没有其它槽」时**直接删文件** —— 于是一次失败的重启/意外退出之后,
140
+ * 现场看起来像「从来没有过常驻实例」,而端口上那个 web 还在跑(孤儿)。现在**保留文件**并留一个
141
+ * `endedAt` 标记的槽:status 会显示「已退出(陈旧记录)」,下次 acquire 再正常回收它。
142
+ */
143
+ export function releaseWatchdogLock({ env = process.env, pid = process.pid, keepEnded = true } = {}) {
133
144
  const cur = readWatchdogLock(env)
134
145
  const kept = cur.watchdogs.filter((w) => w.pid !== pid)
135
146
  if (kept.length === cur.watchdogs.length) return false
136
- if (!kept.length) { try { fs.unlinkSync(watchdogLockPath(env)) } catch { /* 已不存在 */ } return true }
137
- writeWatchdogLock(env, kept)
147
+ const ended = cur.watchdogs.filter((w) => w.pid === pid).map((w) => ({ ...w, endedAt: new Date().toISOString() }))
148
+ writeWatchdogLock(env, kept.length ? kept : (keepEnded ? ended.slice(-1) : []))
149
+ return true
150
+ }
151
+
152
+ /**
153
+ * 把「替换前的锁登记」写回去(issue #30:失败的 restart 不能把登记抹掉)。
154
+ * 只补本端口、且当前没有**活着的**同端口登记时才写,避免覆盖新实例的登记。
155
+ * @returns {boolean} 是否真的写回
156
+ */
157
+ export function restoreWatchdogLock({ env = process.env, port, watchdogs = [] } = {}) {
158
+ const p = Number(port) || 0
159
+ const want = (Array.isArray(watchdogs) ? watchdogs : []).filter((w) => Number(w.port) === p)
160
+ if (!want.length) return false
161
+ const cur = readWatchdogLock(env)
162
+ // 必须排除 endedAt 槽(与 acquireWatchdogLock 同一口径)——报告者实测:PID 复用/正在退出时,
163
+ // 一条本端口的 endedAt 槽会让这里误判「已有活着的登记」而放弃写回。
164
+ const liveSame = cur.watchdogs.filter((w) => Number(w.port) === p && !w.endedAt && isPidAlive(w.pid))
165
+ if (liveSame.length) return false
166
+ const others = cur.watchdogs.filter((w) => Number(w.port) !== p && isPidAlive(w.pid))
167
+ writeWatchdogLock(env, [...others, ...want.map((w) => ({ ...w, restoredAt: new Date().toISOString() }))])
138
168
  return true
139
169
  }
140
170
 
@@ -225,7 +255,8 @@ export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000,
225
255
  const failed = []
226
256
  const skipped = []
227
257
  for (const w of slots) {
228
- if (!alive(w.pid)) { skipped.push(Number(w.pid)); releaseWatchdogLock({ env, pid: w.pid }); continue }
258
+ // 显式清理路径:陈旧槽直接清掉(不留 endedAt 尾巴),与「看门狗自己退出」区分开
259
+ if (!alive(w.pid)) { skipped.push(Number(w.pid)); releaseWatchdogLock({ env, pid: w.pid, keepEnded: false }); continue }
229
260
  if (!killFn(w.pid)) { failed.push(Number(w.pid)); continue }
230
261
  stopped.push(Number(w.pid))
231
262
  }
@@ -251,7 +282,7 @@ export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000,
251
282
 
252
283
  for (const pid of [...stopped, ...failed, ...webStopped]) {
253
284
  if (alive(pid)) { if (pid !== 0) failed.push(pid); continue }
254
- releaseWatchdogLock({ env, pid })
285
+ releaseWatchdogLock({ env, pid, keepEnded: false })
255
286
  }
256
287
 
257
288
  // 端口兜底(issue #23):锁里登记的 webPid 可能压根没写、或者写了却枚举不到,
@@ -360,9 +391,10 @@ async function listWatchdogProcesses(exec, platform = process.platform, marker =
360
391
  * 两条对策:① `try/catch` 包住 spawn,失败就当「枚举不到」;② **用临时文件接 stdout**,完全不碰管道。
361
392
  */
362
393
  function defaultExec() {
363
- return async (cmd, args) => {
394
+ return async (cmd, args, opts = {}) => {
364
395
  const { spawn } = await import('node:child_process')
365
396
  let outFile = ''
397
+ let timer = null
366
398
  try {
367
399
  const os = await import('node:os')
368
400
  const path = await import('node:path')
@@ -375,6 +407,10 @@ function defaultExec() {
375
407
  } finally {
376
408
  try { fs.closeSync(fd) } catch { /* 子进程已继承则关父进程副本 */ }
377
409
  }
410
+ // 超时必须能掐断子进程:issue #30 实测 Windows 上 Get-NetTCPConnection 无条件要 4–7.5 秒
411
+ // (一次 13.6 秒),而调用方(接管清场)的 8000ms 预算根本管不住它 —— 于是决策基于过期数据。
412
+ const tm = Number(opts && opts.timeoutMs) || 0
413
+ if (tm > 0) timer = setTimeout(() => { try { child.kill() } catch { /* 已经在退出 */ } }, tm)
378
414
  await new Promise((resolve) => {
379
415
  child.on('error', () => resolve(''))
380
416
  child.on('close', () => resolve(''))
@@ -383,6 +419,7 @@ function defaultExec() {
383
419
  } catch {
384
420
  return '' // 受限环境:宁可「枚举不到」,也不能把调用方(如 --reap)崩掉
385
421
  } finally {
422
+ if (timer) clearTimeout(timer)
386
423
  if (outFile) { try { (await import('node:fs')).unlinkSync(outFile) } catch { /* 清理失败无妨 */ } }
387
424
  }
388
425
  }
@@ -718,6 +755,23 @@ export async function restartResident({
718
755
  const before = readWatchdogLock(env).watchdogs.filter((w) => Number(w.port) === p)
719
756
  // 替换**前**就存在的 pid(看门狗 + 它拉起的 web):自检时用来区分「还是那个旧实例」与「新实例接管了」
720
757
  const beforePids = new Set(before.flatMap((w) => [Number(w.pid), Number(w.webPid) || 0]).filter((n) => n > 0))
758
+ // 替换前的登记快照:失败时写回去(issue #30 实测:失败的 restart 会把锁文件删掉,
759
+ // 现场从此看起来像「从来没有过常驻实例」,而端口上那个 web 还在跑)。
760
+ const lockSnapshot = before.slice()
761
+ // issue #30(受限环境现场):锁文件写不进去时,接管**无法登记**新实例 —— 继续做只会留下
762
+ // 「有 web 在跑、却没有任何登记」的孤儿(机器上实测:锁目录 EPERM,重启后现场什么都没变、
763
+ // 用户却以为已经替换过)。这里前置拒绝,并说清两种出路。
764
+ if (!deps.skipLockPreflight) {
765
+ const probeLock = deps.probeLockWritable || probeLockWritable
766
+ let lockOk = true
767
+ try { lockOk = Boolean(probeLock(env).writable) } catch { lockOk = true }
768
+ if (!lockOk) {
769
+ return {
770
+ ok: false, stage: 'lock-unwritable', expect, served: '', waitedMs: 0, helperPid: 0, before, lockRestored: false, occupant: null,
771
+ error: '看门狗锁文件写不进去(' + (() => { try { return probeLock(env).path || '' } catch { return '' } })() + ')→ 接管无法登记,已拒绝执行(避免留下"有 web 在跑、却没登记"的孤儿)。请先修复该目录权限,或前台观察:dsh-memory watchdog --port ' + p,
772
+ }
773
+ }
774
+ }
721
775
  let helperPid = 0
722
776
  try {
723
777
  const child = (deps.spawnHelper || spawnReplaceHelper)({ port: p, vaultRoot, interval, maxRestart })
@@ -735,6 +789,21 @@ export async function restartResident({
735
789
  readLock: deps.readLock,
736
790
  })
737
791
  } catch { check = { ok: false, version: '', waitedMs: 0 } }
792
+ // 失败善后:把替换前的登记写回去 + 报告端口上现在是谁(免得用户面对「锁没了、web 还在」的孤儿现场)
793
+ let lockRestored = false
794
+ let occupant = null
795
+ if (!check.ok) {
796
+ try { lockRestored = restoreWatchdogLock({ env, port: p, watchdogs: lockSnapshot }) } catch { lockRestored = false }
797
+ try { occupant = await (deps.findListener || findPortListener)(p) } catch { occupant = null }
798
+ }
799
+ const who = occupant && occupant.pid ? occupant.pid + '(' + (occupant.command || '命令行未知') + ')' : ''
800
+ const occupantText = who ? ';端口上现在是 pid ' + who : ''
801
+ const lockText = (!check.ok && lockSnapshot.length)
802
+ ? (lockRestored ? ';已把替换前的锁登记写回(不再出现「锁没了、web 还在」)' : ';锁登记未能写回(受限环境禁止写锁文件)')
803
+ : ''
804
+ const failMsg = (check.version === expect && !check.tookOver)
805
+ ? '端口 ' + p + ' 服务着 v' + check.version + ',但没有新看门狗接管它(端口上可能仍是替换前的旧实例或孤儿)'
806
+ : '端口 ' + p + ' 上服务的版本是 ' + (check.version || '(无响应)') + ',本机是 ' + (expect || '?')
738
807
  return {
739
808
  ok: Boolean(check.ok),
740
809
  stage: check.ok ? 'done' : 'verify',
@@ -743,11 +812,11 @@ export async function restartResident({
743
812
  waitedMs: Number(check.waitedMs) || 0,
744
813
  helperPid,
745
814
  before,
746
- // 失败原因要分清两种:版本不对(旧实例还在服务)与**没人接管**(端口服务着期望版本,
747
- // 但锁里没有新的活看门狗 —— 端口上那只是旧实例/孤儿)
748
- error: check.ok ? undefined : (check.version === expect && !check.tookOver
749
- ? `端口 ${p} 服务着 v${check.version},但没有新看门狗接管它(端口上可能仍是替换前的旧实例或孤儿)`
750
- : `端口 ${p} 上服务的版本是 ${check.version || '(无响应)'},本机是 ${expect || '?'}`),
815
+ lockRestored,
816
+ occupant,
817
+ // 失败原因:版本不对(旧实例还在服务)vs 没人接管(端口服务着期望版本却没有新看门狗);
818
+ // 并补上「端口上现在是谁」与「锁有没有写回」—— #30 现场最缺的就是这两句。
819
+ error: check.ok ? undefined : (failMsg + occupantText + lockText),
751
820
  tookOver: Boolean(check.tookOver),
752
821
  }
753
822
  }
@@ -762,6 +831,8 @@ export async function restartResident({
762
831
  export async function clearResidentForTakeover({ port = 7999, env = process.env, timeoutMs = 8000, deps = {} } = {}) {
763
832
  const p = Number(port) || 7999
764
833
  const findListener = deps.findListener || findPortListener
834
+ // 枚举本身要能被时间预算约束(否则 8s 预算被一次 13.6s 的 PowerShell 查询吃光,决策基于过期数据)
835
+ const listenerOpts = { timeoutMs: Math.max(500, Math.min(2500, Number(timeoutMs) || 8000)) }
765
836
  const out = { stopped: [], webStopped: [], portStopped: [], warnings: [], failed: [], portFree: false }
766
837
  const rawKill = deps.kill || ((pid) => { try { process.kill(pid, 'SIGTERM'); return true } catch { return false } })
767
838
  // 记下「已经判定收不掉」的 pid:立刻再来一次只会同样失败,白折腾还可能误导用户以为是权限抖动
@@ -783,17 +854,32 @@ export async function clearResidentForTakeover({ port = 7999, env = process.env,
783
854
  }
784
855
  const wait = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
785
856
  let left = null
786
- try { left = await findListener(p) } catch { left = null }
857
+ try { left = await findListener(p, listenerOpts) } catch { left = null }
787
858
  const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0)
788
859
  // 只对「确认是本插件的 web」下手:别人的进程占着端口时只如实报告,不误杀(同 issue #23 的口径)。
789
860
  // 已经判定收不掉的 pid 不再重试 —— 立刻再来一次只会同样失败。
790
861
  while (left && left.pid && looksLikeOurWeb(left.command) && !failedKills.has(Number(left.pid)) && Date.now() < deadline) {
791
862
  if (!killFn(left.pid)) break
792
863
  await wait(200)
793
- try { left = await findListener(p) } catch { left = null }
864
+ try { left = await findListener(p, listenerOpts) } catch { left = null }
865
+ }
866
+ if (left && left.pid) {
867
+ out.portFree = false
868
+ out.warnings.push(`端口 ${p} 仍被 pid ${left.pid} 占用(${left.command || '命令行未知'})`)
869
+ } else {
870
+ // 枚举不到 ≠ 端口是空的(issue #30 现场):受限环境/被拒的进程查询会让 findListener 返回 null,
871
+ // 旧实现据此认为"端口已空出" → 新 web 绑不上 7999 → 退让或失败 → 自检「(无响应)」+ 锁被清空。
872
+ // 这里用**不依赖枚举**的 TCP 探测复核:还在监听就坚决不算空。
873
+ const probe = deps.probeTcp || probeTcp
874
+ let listening = false
875
+ try { listening = Boolean(await probe(p)) } catch { listening = false }
876
+ if (listening) {
877
+ out.portFree = false
878
+ out.warnings.push(`端口 ${p} 仍在监听,但占用者无法识别(进程枚举被拒/不可用)—— 已中止接管,且未改动任何锁登记`)
879
+ } else {
880
+ out.portFree = true
881
+ }
794
882
  }
795
- out.portFree = !(left && left.pid)
796
- if (!out.portFree) out.warnings.push(`端口 ${p} 仍被 pid ${left.pid} 占用(${left.command || '命令行未知'})`)
797
883
  return out
798
884
  }
799
885
 
@@ -831,19 +917,20 @@ export function parseLsofFields(text) {
831
917
  * @param {{exec?: (cmd:string,args:string[])=>Promise<string>}} [opts]
832
918
  * @returns {Promise<{pid:number, command:string}|null>}
833
919
  */
834
- export async function findPortListener(port, { exec, platform = process.platform } = {}) {
920
+ export async function findPortListener(port, { exec, platform = process.platform, timeoutMs = 4000 } = {}) {
835
921
  const p = Number(port)
836
922
  if (!Number.isFinite(p) || p <= 0) return null
837
923
  const runExec = exec || defaultExec()
924
+ const execOpts = { timeoutMs: Math.max(200, Number(timeoutMs) || 4000) }
838
925
  try {
839
926
  if (platform === 'win32') {
840
- const out = await runExec('powershell', ['-NoProfile', '-Command', `Get-NetTCPConnection -LocalPort ${p} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`])
927
+ const out = await runExec('powershell', ['-NoProfile', '-Command', `Get-NetTCPConnection -LocalPort ${p} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`], execOpts)
841
928
  const pid = Number(String(out || '').split(/\r?\n/).map((s) => s.trim()).find((s) => /^\d+$/.test(s))) || 0
842
929
  if (!pid) return null
843
- const cmd = await runExec('powershell', ['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" | Select-Object -ExpandProperty CommandLine)`])
930
+ const cmd = await runExec('powershell', ['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" | Select-Object -ExpandProperty CommandLine)`], execOpts)
844
931
  return { pid, command: String(cmd || '').trim() }
845
932
  }
846
- const fields = await runExec('lsof', ['-nP', `-iTCP:${p}`, '-sTCP:LISTEN', '-Fpcn'])
933
+ const fields = await runExec('lsof', ['-nP', `-iTCP:${p}`, '-sTCP:LISTEN', '-Fpcn'], execOpts)
847
934
  const list = parseLsofFields(fields)
848
935
  if (!list.length) return null
849
936
  const pid = list[0].pid
@@ -1214,6 +1301,12 @@ if (process.argv[1] && /[\\/]watchdog\.js$/.test(process.argv[1])) {
1214
1301
  log(summary)
1215
1302
  if (cleared.failed.length) log('⚠ 未能停止:pid ' + cleared.failed.join(', '))
1216
1303
  if (!cleared.portFree) {
1304
+ // issue #30:**清场失败也必须留下登记**。老实现直接 exit(1),于是锁里什么都没有 ——
1305
+ // 而 restartResident 的自检要求「锁里出现新看门狗」,必然超时,用户看到的永远是「(无响应)」。
1306
+ // 登记一条(pid 已退出时会自动落成 endedAt 记录,不挡后续),现场从此可复盘。
1307
+ const claim = acquireWatchdogLock({ port, vault: vaultRoot || '', pkgVersion: currentPkgVersion(), force: true })
1308
+ if (claim.acquired) log('已登记本次替换尝试(端口仍被占用,看门狗未启动):pid ' + process.pid)
1309
+ else log('⚠ 未能登记本次替换(锁不可写?):' + (claim.file || ''))
1217
1310
  const why = `${summary} —— 端口没空出来,已中止接管(不退让到 port+1,避免又留一个孤儿 web)`
1218
1311
  await noteReplace('fail', why)
1219
1312
  process.exit(1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.28",
3
+ "version": "0.10.31",
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"
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"
68
68
  },
69
69
  "dependencies": {
70
70
  "@deepseek-ai/schemastery": "^3.18.1"