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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.en.md +6 -3
- package/README.md +6 -3
- package/bin/dsh-memory.mjs +16 -0
- package/lib/client.js +12 -12
- package/lib/vault.js +40 -6
- package/lib/watchdog.js +117 -24
- package/package.json +2 -2
- package/web/app.js +15 -15
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:
|
|
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 =
|
|
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(
|
|
671
|
+
const tags = new Set(normalizeTags(first.tags))
|
|
638
672
|
let combined = ''
|
|
639
673
|
rows.forEach((row, i) => {
|
|
640
|
-
const rowTags =
|
|
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:
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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.
|
|
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"
|