memory-eternal 0.10.16 → 0.10.17

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "memory-eternal",
4
- "version": "0.10.16",
4
+ "version": "0.10.17",
5
5
  "description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
6
6
  "author": {
7
7
  "name": "EternalNight996",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.16",
3
+ "version": "0.10.17",
4
4
  "description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
5
5
  "author": {
6
6
  "name": "EternalNight996",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.16",
3
+ "version": "0.10.17",
4
4
  "description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
5
5
  "author": {
6
6
  "name": "EternalNight996",
package/README.en.md CHANGED
@@ -361,14 +361,15 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # is the standalone we
361
361
 
362
362
  | Version | Date | Highlights |
363
363
  |---|---|---|
364
+ | **v0.10.17** | 2026-10-12 | **Root fix for the "resident is serving but nobody manages it" self-contradiction (issue #28)**. The scene: after upgrading to 0.10.16 the reporter had no resident instance at all, and the panel showed **neither a restart button nor any hint** (the banner only rendered when `vc.stale`, which is false when the host and the port report the same version), only an "auto-capture anomaly"; reproduced the other half locally: after `stop` the port was **still serving**, the lock was empty, and `status` said "no registered watchdog" — because that web had been spawned directly by the host's `ensureWeb()` (no `webPid` in the lock) and `stop` only killed the watchdog. Five fixes: ① **`/version-check` now reports `resident.managed`** (a live watchdog in the lock is what makes it managed) — "serving" is not "managed"; ② **panel**: both the **not-running** and the **unmanaged** resident show a restart button with dedicated wording ("not running" / "unmanaged") instead of only the version-drift case; ③ **`/restart-self`**: `alreadyCurrent` now requires a version match **and** a live manager, otherwise it replaces as usual (an orphan web does not get to stay just because its version is new); ④ **an explicit `dsh-memory stop` frees the port** (`forcePortOccupant: true`, same gate as ever: only processes `looksLikeOurWeb` recognises are terminated); ⑤ **the self-check now demands a fresh takeover**: `restartResident` no longer accepts "the port serves the expected version" (before and after an upgrade the version is often identical, so the **old** instance fooled the check — measuring "self-check passed (waited 0s)" immediately followed by `status` reporting no registered watchdog); the new `waitForTakeover()` requires a matching version **and** a live watchdog whose pid is not in the pre-replacement set, and failure reasons are split accordingly into "wrong version" vs "nobody took over". Measured on this machine in the same scenario: `41672/17432` before → `28136/31976` after, `restart` waited 13s and passed, and the **very next `status` showed the new watchdog registered** (no contradiction). `npm test` now runs **310 checks** |
364
365
  | **v0.10.16** | 2026-10-09 | **`--reap` no longer says "nothing was reaped" when it did reap something** (reported by alario-tang, measured on macOS with 0.10.13). The scene: after `kill -9` on the watchdog leaves a `PPID=1` orphan web, `dsh-memory watchdog --reap --port 7999` really freed the port and really killed the web, yet the screen read "**scanned 1 watchdog process, cleaned 0 orphans**" — because 0.10.13 made `scanned` include webs while `webKilled` was never printed. Fix: a pure `formatReapSummary()` unifies the wording (scan counts split into `watchdog A / web B`, cleaned entries listed per kind with pids), `reapStaleWatchdogs` gains `scannedWatchdogs` / `scannedWebs` (`scanned` stays their sum for older callers), and the CLI uses it. Two checks added (never say "cleaned 0 orphans" when a web was reaped / scan counts must be split), and `tests/watchdog-orphan-web.test.mjs` now records the reproduction note: **such an orphan can only be created with `kill -9` — SIGTERM runs the watchdog's graceful exit, which also stops the web it spawned** (the same coin as #19's "on Windows SIGTERM is unconditional termination"). `npm test` now runs **308 checks** |
365
- | **v0.10.15** | 2026-10-09 | **Three loose ends left by the upgrades.** ① **Recommended plan A no longer reinstalls the call we just saved**: v0.10.12 raised the default distiller output cap to 4000 (removing the wasted "hit 2000, then re-run the same candidate at 4000"), but the panel one-click preset "A (light)" still hard-coded `captureMaxTokens: 2000` — one click brought the retry back; it now matches the default, guarded by a source check (`tests/csrf-origin.test.mjs` asserts planA cap === default). ② **The README "stall watchdog" description now matches the new rule** (both languages): the old wording said "if no turn-stopping event arrives for 15 minutes", while it is actually "**zero stops since this process started** plus turns still starting inside the last 20 minutes → record a `warn`", and it now states that an in-flight **long turn is never mistaken for a stall**. ③ While at it, the other log wording `轮次收尾事件未触发(agent/turn-stopping 没到)` was verified to have last fired on **2026-09-24** — a historical remnant of the old heuristic with no such code path left today (nothing to fix). `npm test` now runs **306 checks** |
366
366
 
367
367
  <details>
368
- <summary>Older release notes (v0.10.14 and earlier · 41 releases, click to expand)</summary>
368
+ <summary>Older release notes (v0.10.15 and earlier · 42 releases, click to expand)</summary>
369
369
 
370
370
  | Version | Date | Highlights |
371
371
  |---|---|---|
372
+ | **v0.10.15** | 2026-10-09 | **Three loose ends left by the upgrades.** ① **Recommended plan A no longer reinstalls the call we just saved**: v0.10.12 raised the default distiller output cap to 4000 (removing the wasted "hit 2000, then re-run the same candidate at 4000"), but the panel one-click preset "A (light)" still hard-coded `captureMaxTokens: 2000` — one click brought the retry back; it now matches the default, guarded by a source check (`tests/csrf-origin.test.mjs` asserts planA cap === default). ② **The README "stall watchdog" description now matches the new rule** (both languages): the old wording said "if no turn-stopping event arrives for 15 minutes", while it is actually "**zero stops since this process started** plus turns still starting inside the last 20 minutes → record a `warn`", and it now states that an in-flight **long turn is never mistaken for a stall**. ③ While at it, the other log wording `轮次收尾事件未触发(agent/turn-stopping 没到)` was verified to have last fired on **2026-09-24** — a historical remnant of the old heuristic with no such code path left today (nothing to fix). `npm test` now runs **306 checks** |
372
373
  | **v0.10.14** | 2026-10-09 | **The "auto-capture anomaly: turn-stop event may be broken" false alarm is fixed at the root.** The scene: during a long turn lasting tens of minutes, `agent/inbox/claimed` fires **repeatedly inside one turn**, while `turn-stopping` only arrives when the turn really ends — so any 20-minute window necessarily shows "≥3 starts, 0 stops", and the check mistook an **in-flight** turn for a renamed event, pushing a red alert at the user (the "39/18" line measured in this session came from exactly that). Fix: one hard precondition — "the event was renamed/removed" is a **process-lifetime** property, not a window property: if this run has seen even one stop, the wiring works and 0 stops in the window merely means the current turn is still running (`evaluateStall` gains `everStopped` and reports `reason: wiring-proven | no-stop-ever`); only "zero stops since this process started **and** continuous starts inside the window" alerts. At the same time the alert is downgraded from `fail` to `warn` — an inference no longer paints capture health red nor masquerades as an auto-capture anomaly (real failures still turn it red on their own). Three checks in `tests/stall.test.mjs` lock the old and new behaviour (long turn stays quiet / a real rename alerts / `everStopped` injectable). `npm test` now runs **305 checks** |
373
374
  | **v0.10.13** | 2026-10-09 | **Orphan `web.js` processes are finally reachable (#11 item 1, the POSIX branch)**. The scene (alario-tang, macOS arm64): the watchdog died first and the lock file was gone, leaving a stale web on port 7999 with `PPID=1`; `stop`/`restart` read lock slots and found no target, `watchdog --reap` only matched `watchdog.js`, and the panel button rides the same path — so drift could be reported but never healed, and the user had to `kill` by hand. Two concrete gaps: ① **on POSIX the port occupant only came back as a process name** — `lsof -Fpcn` yields `node` for the `c` field rather than a command line, while the "should we reap it" test (`looksLikeOurWeb`) requires `memory-eternal` or this install web.js absolute path in the command line → always false → the fallback loops in `stopWatchdogs` / `clearResidentForTakeover` **never killed anything** (Windows uses `Get-CimInstance`, which returns the full command line, so this box never showed it); now a follow-up `ps -p <pid> -o args=` fills in the command line, falling back to the old behaviour when unavailable (better to miss than to misfire), with an injectable `platform` for tests; ② **`--reap` now covers our own webs**: a `listWebs` branch reaps "same port + our web + **its keeper is gone** (that slot watchdog is not alive) + outside the protected set (keepPid / current pid / parent / a `--reap` sibling)", while **a web whose keeper is alive is never touched**, and other ports or foreign `web.js` processes are never killed. Adds `tests/watchdog-orphan-web.test.mjs` (5 checks) and makes two watchdog-only cases state `listWebs: []` explicitly (otherwise reap enumerates the real process table). `npm test` now runs **302 checks** |
374
375
  | **v0.10.12** | 2026-10-09 | **Two quiet things that either burn money or leave a hole every day.** ① **Default distiller output cap: 2000 → 4000 (the hard ceiling)**: the logs kept showing "hit the maxTokens=2000 cap → retry the same candidate at 4000", and each occurrence **wasted one LLM call** (hit the cap, then re-run the same candidate at 4000). `maxTokens` is only a ceiling and billing follows what is actually generated, so the default now sits at the hard ceiling: those turns finish in one call; `maxTokenLadder` stays for users who **explicitly** lower `captureMaxTokens`. ② **A cross-site write gate on the plugin API**: `/memory-eternal/api/*` is unauthenticated, so any local web page could POST `/config`, `/audit/approve`, `/write`… New `crossOriginError()` — state-changing requests (non-GET/HEAD/OPTIONS) must be **same-origin with the Host of the request itself**: browsers always send `Origin` on cross-site requests (a mismatch returns 403 `CROSS_ORIGIN_BLOCKED`), while same-origin pages (DSH settings/memory page, the standalone page) and LAN access stay unaffected, curl / CLI / MCP calls without `Origin` keep working, and desktop-shell schemes like `app://` are allowed so nothing breaks. Both the intercepted host routes (`/config` POST, `/restart-self`, `/mcp/action`…) and the shared api entry carry the gate. Adds `tests/csrf-origin.test.mjs` (4 checks: decision matrix incl. LAN / no Origin / null / app://, a cross-site POST getting 403 without reaching business logic, both entry points present, and the default cap equal to the hard ceiling with no ladder retry). `npm test` now runs **297 checks** |
package/README.md CHANGED
@@ -393,14 +393,15 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # 独立 web 是否活
393
393
 
394
394
  | 版本 | 日期 | 关键改动 |
395
395
  |---|---|---|
396
+ | **v0.10.17** | 2026-10-12 | **「常驻实例在服务、却没人托管」这类状态自相矛盾的根治(issue #28)**。现场:用户升级到 0.10.16 后常驻实例没起来,面板**既没有重启按钮也没有任何提示**(横幅只在 `vc.stale` 时渲染,而宿主与端口版本号一致时 `stale` 为假),只看到一条「自动沉淀异常」;本机复现出另一半:`stop` 之后端口上**还活着**、锁里却空了、`status` 说「没有登记的看门狗」—— 因为端口上那个 web 是宿主 `ensureWeb()` 直接拉起的(锁里没有 `webPid`),`stop` 只杀了看门狗。五处一起修:① **`/version-check` 报出 `resident.managed`**(锁里有活着的看门狗才算被托管)—— 「在服务」≠「被托管」;② **面板**:常驻实例**没在跑**或**没被托管**时都显示重启按钮与专门文案(「未运行」/「未托管」),不再只认「版本落后」这一种;③ **`/restart-self`**:`alreadyCurrent` 必须同时满足「版本一致 **且** 被托管」,否则照常替换(孤儿 web 版本再新也得重新托管);④ **显式 `dsh-memory stop` 把端口腾出来**(`forcePortOccupant: true`,闸门不变:只终止 `looksLikeOurWeb` 认得出是自家的进程);⑤ **自检改为「新实例接管」**:`restartResident` 不再只看「端口服着期望版本」(升级前后版本号常常相同、旧实例本就是这个版本 → 自检会被**旧实例**骗过,实测打印「自检通过(等待 0s)」后紧接着 `status` 说没有登记的看门狗),新增 `waitForTakeover()`:必须「版本一致 **且** 锁里出现一个不在替换前 pid 集合里的活看门狗」才算成功,失败原因也据此分离成「版本不对」与「没人接管」。本机同一现场实测:替换前 `41672/17432` → 替换后 `28136/31976`,`restart` 等待 13s 后自检通过,**紧接着的 `status` 立刻显示新看门狗已登记**(不再矛盾)。`npm test` 共 **310 项** |
396
397
  | **v0.10.16** | 2026-10-09 | **`--reap` 的输出不再「明明收了却说没收」**(alario-tang 反馈,macOS + 0.10.13 实测)。现场:`kill -9` 掉 watchdog 造出 `PPID=1` 的孤儿 web 后跑 `dsh-memory watchdog --reap --port 7999`,端口确实被释放、web 确实被收掉,屏幕上却写着「**扫描到 1 个 watchdog 进程,清理孤儿 0 个**」—— 因为 0.10.13 让 `scanned` 含了 web、而 `webKilled` 一个字都没打印。修法:新增纯函数 `formatReapSummary()` 统一口径(扫描数拆成 `watchdog A / web B`、清理项按类分别列出 pid),`reapStaleWatchdogs` 返回值补 `scannedWatchdogs` / `scannedWebs`(`scanned` 仍是两者之和,兼容旧调用方),CLI 改用它。测试补 2 项(收了 web 时不许再说「清理孤儿 0 个」/ 扫描计数必须拆开),并在 `tests/watchdog-orphan-web.test.mjs` 头部记下复现要点:**这类孤儿只能用 `kill -9` 造 —— SIGTERM 会走 watchdog 的优雅退出、把它拉起的 web 一起停掉**(与 #19 里「Windows 上 SIGTERM 即无条件终止」是同一枚硬币的两面)。`npm test` 共 **308 项** |
397
- | **v0.10.15** | 2026-10-09 | **三处「升级留下的尾巴」**。① **推荐方案 A 不再把刚省掉的调用装回来**:v0.10.12 把「蒸馏输出上限」默认提到 4000(省掉「先撞 2000、再以 4000 重跑同一候选」那次白烧),但面板里「一键推荐配置 A(轻量)」仍写死 `captureMaxTokens: 2000` —— 用户点一下就又把重试装回来;现已与默认对齐,并加源码守卫(`tests/csrf-origin.test.mjs` 断言 planA 的上限 === 默认值)。② **README 的「停滞兜底」描述与新判据对齐**(中英两份):旧文案写「15 分钟收不到收尾事件就报」,现在是「**本次运行至今 0 收尾** + 最近 20 分钟内持续有轮次开始 → 记一条 `warn`」,并写明正在进行的**长回合不会被误判**。③ 顺带核实日志里另一种措辞 `轮次收尾事件未触发(agent/turn-stopping 没到)` 最后出现于 **2026-09-24**,属于旧判据的历史残留,现行代码里已无这条路径(无需修复)。`npm test` 共 **306 项** |
398
398
 
399
399
  <details>
400
- <summary>更早的更新日志(v0.10.14 及以前 · 共 41 版,点开查看)</summary>
400
+ <summary>更早的更新日志(v0.10.15 及以前 · 共 42 版,点开查看)</summary>
401
401
 
402
402
  | 版本 | 日期 | 关键改动 |
403
403
  |---|---|---|
404
+ | **v0.10.15** | 2026-10-09 | **三处「升级留下的尾巴」**。① **推荐方案 A 不再把刚省掉的调用装回来**:v0.10.12 把「蒸馏输出上限」默认提到 4000(省掉「先撞 2000、再以 4000 重跑同一候选」那次白烧),但面板里「一键推荐配置 A(轻量)」仍写死 `captureMaxTokens: 2000` —— 用户点一下就又把重试装回来;现已与默认对齐,并加源码守卫(`tests/csrf-origin.test.mjs` 断言 planA 的上限 === 默认值)。② **README 的「停滞兜底」描述与新判据对齐**(中英两份):旧文案写「15 分钟收不到收尾事件就报」,现在是「**本次运行至今 0 收尾** + 最近 20 分钟内持续有轮次开始 → 记一条 `warn`」,并写明正在进行的**长回合不会被误判**。③ 顺带核实日志里另一种措辞 `轮次收尾事件未触发(agent/turn-stopping 没到)` 最后出现于 **2026-09-24**,属于旧判据的历史残留,现行代码里已无这条路径(无需修复)。`npm test` 共 **306 项** |
404
405
  | **v0.10.14** | 2026-10-09 | **「自动沉淀异常:轮次收尾事件疑似失效」误报根治**。现场:几十分钟的长回合里,`agent/inbox/claimed` 在**一轮对话中途**也会多次触发,而 `turn-stopping` 要等回合真的结束才来 —— 于是 20 分钟窗口必然「开始 ≥3、收尾 0」,判据把**还在进行中**的轮次当成「事件被改名」,直接把红警报推给用户(本会话实测那条「累计 39/18」就是这么来的)。修法:加一条硬前提 —— 「事件被改名/移除」是**进程生命周期**属性,不是窗口属性:本次运行里只要收到过哪怕一次收尾,通路就是好的,窗口内 0 收尾只说明当前轮次还没结束(`evaluateStall` 新增 `everStopped`,并在返回值里给出 `reason: wiring-proven | no-stop-ever`);只有「本次运行至今 **0** 收尾 + 窗口内持续开始」才报警。同时把这条**推断**从 `fail` 降级为 `warn` —— 不再由它把沉淀健康状态翻红、更不冒充「自动沉淀异常」(真故障会由写卡失败自己报红)。`tests/stall.test.mjs` 新增 3 项锁住新旧行为(长回合不报 / 真改名要报 / `everStopped` 可注入)。`npm test` 共 **305 项** |
405
406
  | **v0.10.13** | 2026-10-09 | **孤儿 `web.js` 终于收得掉了(#11「待做 1」的 POSIX 分支)**。现场(alario-tang,macOS arm64):watchdog 先死 + 锁文件丢失 → 端口 7999 上留着一个 `PPID=1` 的旧 web;`stop`/`restart` 读锁槽找不到目标、`watchdog --reap` 只认 `watchdog.js`、面板按钮又走同一条链路 → 漂移只能报不能自愈,用户被迫手动 `kill`。查出**两个具体缺口**:① **POSIX 侧端口占用者只拿到进程名** —— `lsof -Fpcn` 的 `c` 字段是 `node` 而不是命令行,而「要不要收」的判据(`looksLikeOurWeb`)要求命令行含 `memory-eternal` 或本机 `web.js` 绝对路径 → 判据恒假 → `stopWatchdogs` / `clearResidentForTakeover` 的兜底循环**一次都不杀**(Windows 走 `Get-CimInstance` 拿完整命令行,所以本机从未暴露);现在补一次 `ps -p <pid> -o args=`,拿不到才退回旧行为(宁可不杀、也不误杀),并让 `platform` 可注入以便单测。② **`--reap` 扩到自家 web**:新增 `listWebs` 分支,把「同端口 + 是自家 web + **托管者已死**(该槽 watchdog 不存活)+ 不在保护集合(keepPid / 当前 pid / 父进程 / 带 `--reap` 的同类)」的 web 一并回收;**托管者还活着的 web 一律不动**,别的端口、别人的 `web.js` 一个不杀。新增 `tests/watchdog-orphan-web.test.mjs`(5 项),并把两条只谈 watchdog 的旧用例显式声明 `listWebs: []`(否则 reap 会去列本机真实进程表)。`npm test` 共 **302 项** |
406
407
  | **v0.10.12** | 2026-10-09 | **两件「不显眼但每天都在扣钱 / 留洞」的事**。① **蒸馏输出上限默认 2000 → 4000(= 硬顶)**:日志实测高频出现「输出撞到 maxTokens=2000 上限 → 以 4000 重试同一候选」,每一次都是**白烧一次 LLM 调用**(先撞顶、再拿 4000 重跑同一个候选)。maxTokens 只是上限、按实际生成量计费,所以默认直接放到硬顶:同样的轮次一次调用就够;`maxTokenLadder` 仍保留给**显式**把 `captureMaxTokens` 调小的用户。② **插件 API 加跨站写入闸门**:`/memory-eternal/api/*` 无鉴权,本机任意网页都能 POST `/config`、`/audit/approve`、`/write`… 新增 `crossOriginError()` —— 状态变更请求(非 GET/HEAD/OPTIONS)必须与请求自身的 `Host` **同源**:浏览器跨站请求一定带 `Origin`(与 Host 不同源即 403 `CROSS_ORIGIN_BLOCKED`);同源页面(DSH 设置页/记忆页、独立页)与局域网访问都同源不受影响;没有 Origin 的 curl / CLI / MCP 照常放行;`app://` 这类桌面壳自定义 scheme 也放行以免误伤。宿主自己的拦截路由(`/config` POST、`/restart-self`、`/mcp/action`…)与共享 api 入口**都**装了这道闸门。新增 `tests/csrf-origin.test.mjs`(4 项:判定矩阵含局域网 / 无 Origin / null / app://、跨站 POST 被 403 且不进业务逻辑、两处入口都在、默认上限=硬顶且无 ladder 重试)。`npm test` 共 **297 项** |
@@ -167,7 +167,10 @@ async function main() {
167
167
  const { stopWatchdogs } = await import('../lib/watchdog.js')
168
168
  const port = argOf('--port')
169
169
  // checkPort:停完再问一次「端口上还有谁在服务」,把「停了却还占着端口」显式说出来(#23)
170
- const out = await stopWatchdogs({ port: port || undefined, checkPort: true })
170
+ // forcePortOccupant:**显式 stop 就该把端口腾出来** —— 端口上那个自家 web 很可能不是看门狗拉起的
171
+ // (宿主 ensureWeb 直接起、或看门狗被 kill -9 后残留的孤儿),锁里没有 webPid 就谁也收不到它。
172
+ // 闸门没松:只有 looksLikeOurWeb(命令行) 为真才会终止,认不出的照旧只告警。
173
+ const out = await stopWatchdogs({ port: port || undefined, checkPort: true, forcePortOccupant: true })
171
174
  if (has('--json')) { console.log(JSON.stringify({ ok: out.failed.length === 0, ...out }, null, 2)); return }
172
175
  if (!out.stopped.length && !out.failed.length && !out.webStopped.length) console.log('没有需要停止的常驻看门狗(锁里没有活着的实例)。')
173
176
  if (out.stopped.length) console.log(`已停止看门狗:pid ${out.stopped.join(', ')}`)
package/index.js CHANGED
@@ -1366,7 +1366,9 @@ export function apply(ctx, config) {
1366
1366
  // 自愈刚把实例挪到配置端口:立刻收敛 web-info,别再让面板指向被收掉的那个端口(issue #29)
1367
1367
  if (out.ok) refreshWebInfo({ url: `http://127.0.0.1:${wdPort}`, port: wdPort, spawned: false })
1368
1368
  console.error(`[memory-eternal] ${done}`)
1369
- logCapture('system', out.ok ? 'boot' : 'fail', done)
1369
+ // 用 warn 不用 fail:这是**服务管理**的失败(常驻实例没起来),不是沉淀管线坏了。
1370
+ // 过去这里记 fail 会把沉淀健康状态翻红,用户看到的是「自动沉淀异常」(实测 #28 的现场)。
1371
+ logCapture('system', out.ok ? 'boot' : 'warn', done)
1370
1372
  return
1371
1373
  }
1372
1374
 
package/lib/api.js CHANGED
@@ -20,7 +20,7 @@ import { buildDiagnostics } from './feedback.js'
20
20
  import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
21
21
  import { backupDb } from './db.js'
22
22
  import { createHub } from './sse.js'
23
- import { probeServedVersion, restartResident, currentPkgVersion } from './watchdog.js'
23
+ import { probeServedVersion, restartResident, currentPkgVersion, readWatchdogLock, isPidAlive } from './watchdog.js'
24
24
 
25
25
  // 独立 web 的 SSE 中心:配置/数据变更即时推给已打开的页面(无需手动刷新)
26
26
  const hub = createHub()
@@ -251,13 +251,21 @@ export function createApi(deps = {}) {
251
251
  const residentPort = selfPort() || Number(cfgVc.webPort) || Number(process.env.MEMORY_WEB_PORT) || 7999
252
252
  let residentServed = ''
253
253
  try { residentServed = String((await probeResidentVersion(residentPort)) || '') } catch { residentServed = '' }
254
+ // 「在服务」≠「被托管」:web 可能是宿主 ensureWeb() 直接拉起的、或看门狗被 stop/kill 后残留的孤儿
255
+ // —— 此时锁里没有活着的看门狗,端口上的实例没人保活、也不会随版本升级被替换。
256
+ // #28 的现场就是这一种:host 与端口版本号一致 → 面板认为「无需重启」,而其实该重启(重新托管)。
257
+ let residentManaged = false
258
+ try {
259
+ residentManaged = readWatchdogLock(process.env).watchdogs
260
+ .some((w) => Number(w.port) === Number(residentPort) && isPidAlive(w.pid))
261
+ } catch { residentManaged = false }
254
262
  json(res, 200, {
255
263
  ok: true,
256
264
  loaded,
257
265
  onDisk,
258
266
  latest,
259
267
  checkError,
260
- resident: { port: residentPort, served: residentServed },
268
+ resident: { port: residentPort, served: residentServed, managed: residentManaged },
261
269
  stale: !!(loaded && onDisk && loaded !== onDisk),
262
270
  // 只在 **npm 上确实更新** 时提示升级:本地跑着未发布版本(磁盘 0.10.7 / npm 0.10.6)时
263
271
  // 旧的 `latest !== onDisk` 会反过来劝用户装回旧版。
@@ -280,10 +288,15 @@ export function createApi(deps = {}) {
280
288
  const port = Number(query.get('port')) || selfPort() || Number(cfgR.webPort) || Number(process.env.MEMORY_WEB_PORT) || 7999
281
289
  const expect = pkgVersion()
282
290
  const served = await probeResidentVersion(port).catch(() => '')
283
- if (served && expect && String(served) === String(expect)) {
291
+ // 「版本一致」还不够:还要**有活着的看门狗托管它**。孤儿 web(看门狗被 stop / 被 kill -9 后残留)
292
+ // 版本号可能正好也是新的,但没人保活、也不会被后续升级替换 —— 那时一键重启才是唯一出路(#28)。
293
+ const managed = (() => {
294
+ try { return readWatchdogLock(process.env).watchdogs.some((w) => Number(w.port) === Number(port) && isPidAlive(w.pid)) } catch { return false }
295
+ })()
296
+ if (served && expect && String(served) === String(expect) && managed) {
284
297
  return json(res, 200, {
285
- ok: true, alreadyCurrent: true, port, served: String(served), expect,
286
- note: `常驻实例(端口 ${port})已经在服务 v${served},无需重启。若「运行中」仍显示旧版本号,说明那一行来自另一个进程(例如 DSH 宿主),需重启桌面版 / DSH。`,
298
+ ok: true, alreadyCurrent: true, port, served: String(served), expect, managed,
299
+ note: `常驻实例(端口 ${port})已经在服务 v${served} 且有看门狗保活,无需重启。若「运行中」仍显示旧版本号,说明那一行来自另一个进程(例如 DSH 宿主),需重启桌面版 / DSH。`,
287
300
  })
288
301
  }
289
302
  if (residentRestarting) {
@@ -304,7 +317,8 @@ export function createApi(deps = {}) {
304
317
  try {
305
318
  const { appendCaptureLog } = await import('./capture-log.js')
306
319
  await appendCaptureLog({
307
- time: Date.now(), sessionId: 'system', action: out && out.ok ? 'boot' : 'fail',
320
+ // 同上:服务管理失败用 warn(别翻红沉淀健康状态、别冒充「自动沉淀异常」)
321
+ time: Date.now(), sessionId: 'system', action: out && out.ok ? 'boot' : 'warn',
308
322
  reason: out && out.ok
309
323
  ? `面板一键重启常驻实例成功:端口 ${port} 正在服务 v${out.served}`
310
324
  : `面板一键重启常驻实例未确认生效:${(out && out.error) || '未知原因'}`,