# 事后复盘:上一版本(v0.12.0 运行时)暴露的致命问题 > 取证日期:2026-11-03 取证方式:**只读**(解压全部 v4 日志、只解析 `tool/call` 的**参数**;扫描 > `session_projcache`;读三个台账;全树扫锁)。本文件记录的是**现场事实**,不是推测。 > 路径一律脱敏为 `%DSH_HOME%` / `` / ``。 复现命令(本次新增,随包一起提交): ```powershell node scripts/debug-status.mjs --data --session --workspace node scripts/prune-ghosts.mjs # 默认 dry-run ``` --- ## 0. 先澄清三件「不是问题」的事 | 当时的预设 | 现场事实 | |---|---| | 有几个会话在疯狂全局搜索并产生幻觉 | **没有**。10 份持久化日志全部已闭合(未闭合 `turn` = 0);**没有 `rg /`、没有 `C:\` 递归扫描**。7 个历史会话的越界调用合计 195 次、**0 次报错**,全部是开发插件时对 profile 与沙箱实例目录的只读检查。 | | 有残留 `.lock` 卡住系统 | **一个都没有**(`%DSH_HOME%` + 工作区全树 0 命中)。 | | 记忆污染是“模型幻觉” | **不是幻觉,是状态固化**:一条一次性指令被当成长期约束,沿 5 代复制(见问题 2)。 | 把这三条先写清楚,是因为**误修比不修更贵**:真正致命的问题都在下面,且都能用代码证据定位。 --- ## 1. 致命问题:连环切片 —— 守卫一直存在,却恰好在无人值守那条路上被短路 **代码证据**(v0.12.0):`isProtectedGraftTarget()`(“目标前 N 轮不许再被切”)嵌在 `if (options?.ignoreTransferGuard !== true)` 内部,而**两条自动路径都传了 `ignoreTransferGuard: true`** (75% 兜底强切、确认模式)。也就是说:**恰恰在没有人点确认的那条路上,连环守卫被跳过了。** **现场证据**:状态里确实躺着一条 5 代链—— `auto-graft-…tmid5b`(第 1 代)→ `auto-graft-…dl9pef`(第 2 代)→ `handoff-v74-final` → `handoff-v75-memory` → `handoff-v76-hardening`。每一代都是同一个形状:目标被塞进一条**继承自源会话的 超水位 tape**,于是“刚建好就再次越线”,可以无限自我复制。 **为什么危险**:每次搬迁都把原始意图埋深一层,且源会话被退休、用户失去对照物。这是**唯一一个能自己 把系统跑爆**的问题——其它问题都需要外部输入才能触发。 **修复**:守卫改为无条件生效;只有显式 `forceSlice`(测试/运维定向恢复)能绕过,自动路径都不传; 70% nudge 对处于 grace 窗口的目标也一并抑制(不再邀请它自杀);目标注入 `graft_generation`。 **回归**:`test/v81-guardrails.test.mjs`(含“过了 grace 窗口后链路守卫停止干预”的反向断言)。 --- ## 2. 致命问题:一次性动作指令被当成长期约束,并沿 5 代复制 **现场证据**:`.graft-constraint-sections.json` 的 5 个 key(= 5 代迁移)**每一个**的 8 条约束里, 第 1 条都是同一句: ``` 1. 删除危险脚本:立刻删除 D:\…\repair-log.cjs,绝不能让这个无备份、强制覆盖的脚本再跑一次。 ``` 那个文件早已不存在,而这条指令还在**每一代**的 HARD CONSTRAINTS 里,和“保护工作区”的规则并列。 **为什么会漏**:抽取器的判据是“祈使句 + 动作 + 够具体 + 够长”,这条全部满足,得分很高。但它是**任务步骤**: 只安全一次,之后就是把枪口对准该路径下一个东西。 **修复**:`extractConstraints`(正则)与 LLM 约束提示**两条路径同时**排除“具体产物 + 变更动词 + 无长期标记 (禁止/绝不/每次/任何/never/always/only…)”的句子。豁免窗口刻意只有 12 字符——那句污染物里也有“绝不”, 但说的是**另一个动作**;标记必须压在动作上。 **回归**:`test/v80-guard.test.mjs`(丢弃一次性删除、保留“绝对禁止在没有备份的情况下删除任何脚本”)。 --- ## 3. 致命问题:提示是「请求」,不是「拒绝」 **现场形状**:一个会话被要求找某个文件,在自己的目录树里找不到,于是**一路外扩**——先 profile、再 `AppData`、再到盘符根——一轮一轮地搜,并对一个“只是列过目录”的文件系统给出自信的总结。 v0.13 把红线写进了 system prompt,但 system prompt 是**建议**:模型可以读、可以同意、然后照样扫全盘。 **修复**:`ctx.tools.guard()` 单调拒绝(命中即在 dispatch 前返回理由,任何其他 guard 都无法改回允许); 同时保留两处**提醒**通道(system-prompt section + pre-step 一次性注入)。 判据刻意窄:拒绝「搜索类动词 + 逃逸目标」与「破坏性动词 + 逃出工作区的递归目标」;读取某个**具体** `C:\...` 文件、工作区内部的 `rg` 与 `Remove-Item -Recurse .\build` 都不拦——**会拦正常工作的护栏活不过一天**。 **本轮自我验证**:在加固完成后,我自己的一条 `Get-ChildItem` + `$env:USERPROFILE` 搜索被这道护栏拒绝, 拒绝理由里附带了替代方案。这是这道防线第一次在真实会话上生效的实录。 --- ## 4. 致命问题:防注入扫描会被「渲染器的截断」击败 hint 渲染把标题 `clipTitle` 截到 30 字符,于是 `Ignore all previous instructions` 变成 `Ignore all previous instructio…`——**模式不再匹配**。扫描器只看得见渲染后的字符串,就被渲染器自己的 截断放过了。(修复在 `dsh-memory` 侧,见该仓库复盘;graft 侧同一类问题由 `extractConstraints` 的 逐句过滤覆盖。) --- ## 5. 致命问题:死锁没有回收者 锁是**目录** + 一个 `owner`(token + pid + lease)。正常路径只在自己**去争用**时才偷过期锁——而一个 当前没人写的状态文件,正是没人争用的时候。崩溃的写者留下的 `.lock` 会一直躺在那里。 **修复**:`apply()` 启动时 `scanAndReclaimLocks()`:按 **owner PID 判活**,死进程的锁目录回收并写审计, **活持有者绝不碰**(删活锁 = 两个写者同时改一份台账)。诊断面板走 `dryRun`,只报告不动手。 **回归**:`test/v81-guardrails.test.mjs`(死锁回收 / 活锁保留 / dryRun 不删 / 激活期扫描)。 --- ## 6. 连带问题:564 条「幽灵会话」占着侧栏 `session_projcache` 共 **574** 条,其中只有 **10** 条有对应日志;其余 **564** 条的会话日志早已不在磁盘上 (约 311 条是本插件自己的压测产物:`ruler-*` / `c15-*` / `extract-*` / `card-*` / `compint-*` …)。 **处置**:`scripts/prune-ghosts.mjs` **隔离而非删除**——对日志已消失的会话,那份缓存是它仅存的痕迹。 移入 `_quarantine-ghost-/`,写 `MANIFEST.json`(原路径/大小/sha256)+ `restore.ps1`,并重置派生状态。 执行结果:缓存 **574 → 10**、`retired` **5 → 0**、`targets` **8 → 0**。 --- ## 7. 修复后的验证 | 项 | 结果 | |---|---| | 单元/集成测试 | **321/321** | | 文档与代码一致性 | `check-docs` 27 项一致 | | 打包卫生 | `verify-bundle` 27/27 | | 真机注册表 | `graft_debug_status` 可查、命令护栏在真实会话上拒绝过一次 | | 状态 | locks 0、txn 0、retired 0、targets 0 | 仍未在真机上触发的一项:**70% 主动转生**(本会话真实水位 1.14%,不伪造水位)。触发逻辑由 `test/v76-*` 与 `test/v81-guardrails.test.mjs` 覆盖。