sage-mem 0.9.12 → 0.9.14
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/autodream/engine.js +10 -3
- package/lib/autodream/gate.js +107 -13
- package/lib/autodream/rollback.js +117 -20
- package/lib/autodream/snapshot.js +51 -5
- package/lib/autodream-audit.js +34 -2
- package/lib/autodream-tools.js +22 -1
- package/lib/client.js +158 -92
- package/lib/index.js +21 -2
- package/lib/memory/access.js +22 -1
- package/lib/memory/archive.js +158 -12
- package/lib/memory/index-file.js +49 -3
- package/lib/memory/util.js +104 -1
- package/lib/typert.host.js +38 -4
- package/package.json +8 -8
package/lib/autodream/engine.js
CHANGED
|
@@ -119,8 +119,9 @@ export class AutodreamEngine {
|
|
|
119
119
|
async releaseLock() { return gateMod.releaseLock.call(this) }
|
|
120
120
|
|
|
121
121
|
// ── 回滚点(快照):实现在 lib/autodream/snapshot.js ──
|
|
122
|
-
|
|
123
|
-
async
|
|
122
|
+
// `opts.protect` = 本轮轮转不许淘汰的快照目录名(回滚要用它自己的源,别被自己删掉)。
|
|
123
|
+
async snapshot(id, kind = 'run', opts) { return snapshotMod.snapshot.call(this, id, kind, opts) }
|
|
124
|
+
async pruneSnapshots(protect) { return snapshotMod.pruneSnapshots.call(this, protect) }
|
|
124
125
|
async resolveSnapshotDir(id) { return snapshotMod.resolveSnapshotDir.call(this, id) }
|
|
125
126
|
async verifySnapshot(dir, opts) { return snapshotMod.verifySnapshot.call(this, dir, opts) }
|
|
126
127
|
async listSnapshots() { return snapshotMod.listSnapshots.call(this) }
|
|
@@ -459,10 +460,16 @@ export class AutodreamEngine {
|
|
|
459
460
|
for (const c of candidates) {
|
|
460
461
|
const reason = `自动归档:${c.type} 类,超过 ${c.limit} 天未被注入`
|
|
461
462
|
const res = await archiveMemory(this.memoryDir, c.file, reason)
|
|
463
|
+
// 索引没摘掉要跟着进报告(复审 R11 的配套):报告是自动路径上唯一被人看到的地方。
|
|
464
|
+
if (res.ok && res.indexError) {
|
|
465
|
+
this.log?.('warn', `自动归档:${c.file} 的索引没摘掉 —— ${res.indexError}`)
|
|
466
|
+
}
|
|
462
467
|
archived.push({
|
|
463
468
|
file: c.file,
|
|
464
469
|
ok: res.ok === true,
|
|
465
|
-
message: res.ok
|
|
470
|
+
message: res.ok
|
|
471
|
+
? `已归档 ${res.file}(原因:${res.archivedReason})${res.indexError ? `;⚠ 索引没摘掉:${res.indexError}` : ''}`
|
|
472
|
+
: res.error,
|
|
466
473
|
})
|
|
467
474
|
}
|
|
468
475
|
return { mode: 'auto', requested, candidates, archived, degraded: '' }
|
package/lib/autodream/gate.js
CHANGED
|
@@ -83,11 +83,20 @@ export async function acquireLock() {
|
|
|
83
83
|
*/
|
|
84
84
|
const before = await readJson(this.lockPath)
|
|
85
85
|
const grabbed = `${this.lockPath}.stale-${randomUUID().replace(/-/g, '')}`
|
|
86
|
-
const
|
|
87
|
-
() =>
|
|
88
|
-
() =>
|
|
86
|
+
const moveErr = await rename(this.lockPath, grabbed).then(
|
|
87
|
+
() => null,
|
|
88
|
+
(err) => err,
|
|
89
89
|
)
|
|
90
|
-
if (
|
|
90
|
+
if (moveErr) {
|
|
91
|
+
/**
|
|
92
|
+
* ⚠️ 失败要分流,否则「占用/权限类失败」会让这条路径**递归到自己爆栈**
|
|
93
|
+
* (旧写法 `if (!moved) return this.acquireLock()`:锁没被移走,重来再走同一条分支,
|
|
94
|
+
* 一路递归下去)。只有 ENOENT(别人抢先移走了、锁位确实空着)重来才有意义。
|
|
95
|
+
*/
|
|
96
|
+
if (moveErr?.code === 'ENOENT') return this.acquireLock() // 别人抢先移走了,直接重来
|
|
97
|
+
this.log?.('warn', `抢占过期锁失败(rename ${moveErr?.code ?? moveErr}):${this.lockPath} 没能移走;${moveErr?.message ?? moveErr}`)
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
91
100
|
const movedInfo = await stat(grabbed).catch(() => null)
|
|
92
101
|
const after = await readJson(grabbed)
|
|
93
102
|
const stillStale = movedInfo !== null && Date.now() - movedInfo.mtimeMs > LOCK_STALE_MS
|
|
@@ -98,11 +107,20 @@ export async function acquireLock() {
|
|
|
98
107
|
() => true,
|
|
99
108
|
() => false,
|
|
100
109
|
)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
if (restored) {
|
|
111
|
+
// 名字已经回到原位,这个私有名可以收掉了。
|
|
112
|
+
await rm(grabbed, { force: true }).catch((err) => {
|
|
113
|
+
this.log?.('warn', `锁抢占复核不通过且遗留文件清不掉:${grabbed}(${err?.message ?? err})`)
|
|
114
|
+
})
|
|
115
|
+
} else {
|
|
116
|
+
/**
|
|
117
|
+
* ⚠️ 还回失败(锁位置已被第三方占用)时**不能删 `grabbed`**。
|
|
118
|
+
*
|
|
119
|
+
* 它是我们误移走的那份锁的唯一落身处,删掉等于「既没还回去、又把人家的凭据抹了」
|
|
120
|
+
* —— 与 R05 同一个根因。留着它:名字带 `.stale-` 后缀,不会被任何逻辑当成锁路径,
|
|
121
|
+
* 内容还在磁盘上,排错时看得见。
|
|
122
|
+
*/
|
|
123
|
+
this.log?.('warn', `锁抢占复核不通过,且锁位置已被占用 —— 未覆盖它;被移走的那份留在 ${grabbed}`)
|
|
106
124
|
}
|
|
107
125
|
return false
|
|
108
126
|
}
|
|
@@ -122,6 +140,28 @@ export async function acquireLock() {
|
|
|
122
140
|
* 会把**别人中途抢到的新锁**删掉 —— 于是两个整理进程同时往里写,而两边都以为自己独占。
|
|
123
141
|
* 判据落在锁文件里的 `token` 上:不是自己的,一个字都不动。
|
|
124
142
|
*
|
|
143
|
+
* ⚠️ 光有 token 判据还不够(2026-10-12 复审 R05):「读锁 → 比 token → rm」是三次独立
|
|
144
|
+
* 系统调用,**比较通过之后、rm 之前**别人完全可能抢走过期锁并写入新 token(实测用
|
|
145
|
+
* 两个真实进程 + IPC 暂停点即可稳定复现:新持有者拿到了锁,随即被旧持有者删掉)。
|
|
146
|
+
* 判据正确的写法必须让「删」这个动作本身带上身份前提 —— 这里复用 `acquireLock` 里
|
|
147
|
+
* 抢占过期锁的同一套手法:
|
|
148
|
+
*
|
|
149
|
+
* ① `rename(lockPath, 私有名)` —— **原子地**把锁文件移走。同一把锁只可能被一个进程移走;
|
|
150
|
+
* ② 读移走的那份,token 是**自己的** → `rm` 私有名,释放完成;
|
|
151
|
+
* ③ token **不是自己的**(说明我们移走的是别人刚建的新锁)→ 用 `link()` 还回去。
|
|
152
|
+
* `link` 在锁位置已被占用时报 EEXIST,**绝不覆盖**;还回去了就什么都不少。
|
|
153
|
+
*
|
|
154
|
+
* 顺序是**三层**:预检(廉价,挡住绝大多数)→ `rename` 抢占(原子)→ 复核 + 还回(兜换主)。
|
|
155
|
+
* 三层缺一不可:只有预检就是原来的 R05;只有 rename + 复核则会留下「锁位空着的窗口」——
|
|
156
|
+
* 第三方在那个窗口里 `open(wx)` 能直接拿到锁(三实例实测),所以预检必须先做。
|
|
157
|
+
*
|
|
158
|
+
* 两条已知残留,说清楚不藏:
|
|
159
|
+
* · 预检通过之后、`rename` 之前换主 → 移走别人的锁 → 复核发现 → `link` 还回(不会删错东西);
|
|
160
|
+
* 若此刻锁位已被第三方占用,还回失败,那份锁会**留在 `.released-*`**(内容不丢,可人工捞,
|
|
161
|
+
* 不会被任何路径当成锁)。
|
|
162
|
+
* · 进程在本函数中途被 kill → 锁位可能空着,后来者免过期直接拿到锁。这是「崩溃」的代价,
|
|
163
|
+
* 与「两把锁同时有效」不同:那时没有任何进程会以为自己还持着已被移走的那把。
|
|
164
|
+
*
|
|
125
165
|
* ⚠️ 边界说清楚:`token` 明文躺在锁文件里,**它不是秘密**,防的是「协作的两个实例各跑各的」
|
|
126
166
|
* 这类误删,不防「能读这个目录的对手」—— 那种对手本来就能直接删锁文件。
|
|
127
167
|
*/
|
|
@@ -129,13 +169,67 @@ export async function releaseLock() {
|
|
|
129
169
|
const token = this.lockToken
|
|
130
170
|
// 没持有过(acquireLock 从没成功过)→ 什么都不做。旧实现会在这里删掉别人的锁。
|
|
131
171
|
if (!token) return
|
|
132
|
-
|
|
133
|
-
|
|
172
|
+
/**
|
|
173
|
+
* ① **廉价预检**:锁位上现在这把还是我的吗?不是就**一个动作都不做**。
|
|
174
|
+
*
|
|
175
|
+
* 这一步不能省(复审复核实测的三实例反例):只靠「rename 移走 + 复核 + 还回」的话,
|
|
176
|
+
* 从 `rename` 成功到 `link` 还回之间,**锁位置是空的** —— 第三方在这个窗口里
|
|
177
|
+
* `open(wx)` 能直接拿到锁,而原来那把(B 的)还躺在 `.released-*` 里;
|
|
178
|
+
* 终态是「B 以为独占、C 也拿着锁」,正是 R05 要防的两个整理进程同时写。
|
|
179
|
+
* 预检把绝大多数情况挡在 `rename` 之前(旧实现本来就有这一步,不该丢),
|
|
180
|
+
* `rename + 复核 + 还回` 只用来兜「预检通过之后才换主」那一小段。
|
|
181
|
+
*/
|
|
182
|
+
const head = await readJson(this.lockPath)
|
|
183
|
+
if (!head || head.token !== token) {
|
|
184
|
+
this.lockToken = ''
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
const grabbed = `${this.lockPath}.released-${randomUUID().replace(/-/g, '')}`
|
|
188
|
+
const moveErr = await rename(this.lockPath, grabbed).then(
|
|
189
|
+
() => null,
|
|
190
|
+
(err) => err,
|
|
191
|
+
)
|
|
192
|
+
if (moveErr) {
|
|
193
|
+
/**
|
|
194
|
+
* ⚠️ 这里**必须分流**(复审复核实测,真机 Windows):`rename` 失败的原因不只有
|
|
195
|
+
* 「锁已经不在」。锁文件被占用(AV / 同步盘 / 只读共享)时报 EBUSY / EPERM / EACCES,
|
|
196
|
+
* 那时锁**还留在盘上**,而旧写法把它和 ENOENT 混成一个分支、清掉 token 就返回 ——
|
|
197
|
+
* 一行日志都没有,engine 以为自己释放了,autodream 却被自己的判据挡满 LOCK_STALE_MS。
|
|
198
|
+
* `util.js` 头注那条「非 ENOENT 的失败必须落到日志」正在这条路上。
|
|
199
|
+
*/
|
|
200
|
+
if (moveErr?.code === 'ENOENT') {
|
|
201
|
+
// 锁确实不在了(另一实例抢先释放 / 被人工清掉):没有可删的东西,也不算失败。
|
|
202
|
+
this.lockToken = ''
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
this.log?.('warn', `释放锁失败(rename ${moveErr?.code ?? moveErr}):${this.lockPath} 没能移走,锁可能仍在盘上;${moveErr?.message ?? moveErr}`)
|
|
206
|
+
// 保留 lockToken:锁大概还在,下次再试;也让调用方看得见这次没释放成功。
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
const held = await readJson(grabbed)
|
|
210
|
+
if (held?.token !== token) {
|
|
211
|
+
// 预检之后、移走之前被换主了:移走的是**别人**的锁,还回去。`link` 不覆盖已存在的锁位置。
|
|
212
|
+
const restored = await link(grabbed, this.lockPath).then(
|
|
213
|
+
() => true,
|
|
214
|
+
() => false,
|
|
215
|
+
)
|
|
216
|
+
if (restored) {
|
|
217
|
+
await rm(grabbed, { force: true }).catch((err) => {
|
|
218
|
+
this.log?.('warn', `释放锁:归还成功后残留文件清不掉:${grabbed}(${err?.message ?? err})`)
|
|
219
|
+
})
|
|
220
|
+
} else {
|
|
221
|
+
/**
|
|
222
|
+
* ⚠️ 归还失败时**不能删 `grabbed`**:那是被我们误移走的那份锁的唯一落身处。
|
|
223
|
+
* 删掉它就成了「既没还回去、又把人家的凭据抹了」—— 与 R05 同一个根因。
|
|
224
|
+
* 名字带 `.released-` 后缀,不会在任何路径上被当成锁;内容留着,排错时看得见。
|
|
225
|
+
*/
|
|
226
|
+
this.log?.('warn', `释放锁:移走的不是自己的锁,且锁位置已被占用 —— 未覆盖它;被移走的那份留在 ${grabbed}`)
|
|
227
|
+
}
|
|
134
228
|
this.lockToken = ''
|
|
135
229
|
return
|
|
136
230
|
}
|
|
137
|
-
await rm(
|
|
138
|
-
this.log?.('warn',
|
|
231
|
+
await rm(grabbed, { force: true }).catch((err) => {
|
|
232
|
+
this.log?.('warn', `释放锁:删自己的私有名失败(${err?.message ?? err});残留 ${grabbed}`)
|
|
139
233
|
})
|
|
140
234
|
this.lockToken = ''
|
|
141
235
|
}
|
|
@@ -11,9 +11,10 @@ import { readJson, safeName, snapshotEntries, stampCompact, stampHuman } from '.
|
|
|
11
11
|
import { restoreMemory } from '../memory/archive.js'
|
|
12
12
|
import { nameKey } from '../memory/naming.js'
|
|
13
13
|
import { stripArchiveMeta } from '../memory/scan.js'
|
|
14
|
+
import { writeFileAtomic } from '../memory/util.js'
|
|
14
15
|
import { randomUUID } from 'node:crypto'
|
|
15
|
-
import {
|
|
16
|
-
import { dirname, join } from 'node:path'
|
|
16
|
+
import { chmod, link, lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
17
|
+
import { basename, dirname, join } from 'node:path'
|
|
17
18
|
|
|
18
19
|
/** 读回滚留痕(面板也许要显示「最近谁退回过」)。 */
|
|
19
20
|
export async function listRollbacks(limit = 20) {
|
|
@@ -107,7 +108,17 @@ export async function rollback(opts = {}) {
|
|
|
107
108
|
// 带随机后缀:秒级时间戳会撞(同一秒内连点两次回滚是真实用法),
|
|
108
109
|
// 而撞车的后果是**覆盖掉上一次的保护快照**——那等于把「回滚也能退」这条承诺吃掉。
|
|
109
110
|
const protectionId = `pre-rollback-${stamp}-${randomUUID().replace(/-/g, '').slice(0, 4)}`
|
|
110
|
-
|
|
111
|
+
/**
|
|
112
|
+
* ⚠️ 把选中的回滚源列进**轮转豁免名单**(2026-10-12 复审 R03)。
|
|
113
|
+
*
|
|
114
|
+
* 保护快照走的是 `snapshot()`,而它末尾会轮转旧快照。被选中的源若正好是最旧的
|
|
115
|
+
* 那份保护快照,就会在**恢复开始之前**被这趟回滚自己删掉:之后枚举源得到空集合,
|
|
116
|
+
* 回滚以 `ok:true / restored:0` 收场 —— 报成功、什么都没恢复,而人以为退回去了。
|
|
117
|
+
* 「正在被使用的回滚点」不该参与淘汰。豁免只对当前快照根里的那份有意义(旧版根不轮转)。
|
|
118
|
+
*/
|
|
119
|
+
const sourceDirName = basename(snapDir)
|
|
120
|
+
const protect = dirname(snapDir) === this.snapshotRoot ? [sourceDirName] : []
|
|
121
|
+
const protection = await this.snapshot(protectionId, 'pre-rollback', { protect })
|
|
111
122
|
|
|
112
123
|
/**
|
|
113
124
|
* ⚠️ 保护快照不完整就**中止**,一个字都不许写。
|
|
@@ -139,7 +150,65 @@ export async function rollback(opts = {}) {
|
|
|
139
150
|
|
|
140
151
|
// ② 恢复
|
|
141
152
|
this.phase = '恢复文件'
|
|
153
|
+
/**
|
|
154
|
+
* ②-a 开工前**再确认一次源还在**。
|
|
155
|
+
*
|
|
156
|
+
* 上面那句 `protect` 是根治,这里再加一道兜底:从校验(`verifySnapshot`)到现在,
|
|
157
|
+
* 中间夹着一次完整的保护快照(建目录、逐文件复制、轮转删除),期间外部完全可能
|
|
158
|
+
* 把快照根清掉或轮转掉。源的消失意味着「恢复」退化成「把顶层文件按『快照里没有』
|
|
159
|
+
* 处理」—— 那是**比不回滚更糟**的结局。宁可中止,一个字不写。
|
|
160
|
+
*/
|
|
161
|
+
if (!(await stat(snapDir).catch(() => null))?.isDirectory()) {
|
|
162
|
+
this.log('warn', `回滚中止:回滚点 ${snapshotId} 在恢复动作开始前消失了`)
|
|
163
|
+
this.phase = ''
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
error: `回滚点 ${snapshotId} 在恢复动作开始前消失了(可能被轮转或人工清理)。这次回滚已中止,记忆目录一个字没动。`,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
142
169
|
const snapNames = await snapshotEntries(snapDir)
|
|
170
|
+
/**
|
|
171
|
+
* 名录为空而清单说有文件 → 源被掏空了(轮转删目录内容、杀软隔离、外部清理)。
|
|
172
|
+
* 照旧往下走的话,整目录回滚会把顶层**每一条记忆**都判成「快照里没有」→ 全部移进
|
|
173
|
+
* `archive/`,然后报 `ok:true / restored:0`。这是必须挡死的方向。
|
|
174
|
+
*/
|
|
175
|
+
const expectedSnapFiles = Array.isArray(manifest?.files) ? manifest.files.length : 0
|
|
176
|
+
if (!snapNames.length && expectedSnapFiles > 0) {
|
|
177
|
+
this.log('warn', `回滚中止:回滚点 ${snapshotId} 的清单记着 ${expectedSnapFiles} 个文件,但目录里一个都没有`)
|
|
178
|
+
this.phase = ''
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
error: `回滚点 ${snapshotId} 的内容不见了(清单记着 ${expectedSnapFiles} 个文件,目录里一个都没有)。为免把现有记忆全部误判成「多余的」而移进 archive/,这次回滚已中止,记忆目录一个字没动。`,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* **写回一份快照内容**。
|
|
186
|
+
*
|
|
187
|
+
* ⚠️ 用 `writeFileAtomic`(写临时文件 + rename 替换**目录项**),**不用 `copyFile`**
|
|
188
|
+
* (2026-10-12 复审 R02)。`copyFile` 是打开目标、往它**已有的 inode** 里灌内容,
|
|
189
|
+
* 于是:
|
|
190
|
+
* - 目标有硬链接(`cp -al` / `rsync --link-dest` 做的快照备份)→ 记忆目录**之外**那份
|
|
191
|
+
* 备份被一起改掉,实测复现;
|
|
192
|
+
* - 目标是符号链接 / 目录联接 → 内容穿过链接,写到目录外的真实文件上,实测复现。
|
|
193
|
+
* rename 之后硬链接关系自然解除(备份保持原样),符号链接被**替换成本身**而不是穿透。
|
|
194
|
+
* 这正是「把记忆目录恢复成快照那一版」该有的语义:动的是这个名字,不是它指向的那个 inode。
|
|
195
|
+
*/
|
|
196
|
+
const writeBack = async (name, snapName) => {
|
|
197
|
+
const target = join(this.memoryDir, name)
|
|
198
|
+
/**
|
|
199
|
+
* 记下目标原来的权限位,写完再贴回去。
|
|
200
|
+
*
|
|
201
|
+
* 为什么(复审复核点出的 R02 副作用):`writeFileAtomic` 是「新建临时文件 + rename」,
|
|
202
|
+
* 新文件的 mode 走 umask;而 `copyFile`(旧实现)会沿用源文件的 mode。于是一个被用户
|
|
203
|
+
* 设成 0600 的记忆文件,回滚一趟就变成 0644 —— 隐私被悄悄放宽。
|
|
204
|
+
* 用 `lstat`:目标是符号链接时不该跟随(`stat` 会拿到链接指向的外部文件权限)。
|
|
205
|
+
* 只有**普通文件**才贴回原权限;链接 / 新建文件用默认值。Windows 上 chmod 是 no-op。
|
|
206
|
+
*/
|
|
207
|
+
const before = await lstat(target).catch(() => null)
|
|
208
|
+
const keepMode = before && before.isFile() && typeof before.mode === 'number' ? before.mode : null
|
|
209
|
+
await writeFileAtomic(target, await readFile(join(snapDir, snapName)))
|
|
210
|
+
if (keepMode !== null) await chmod(target, keepMode).catch(() => {})
|
|
211
|
+
}
|
|
143
212
|
/**
|
|
144
213
|
* 快照名录按**归一化名字**索引(`nameKey`:小写归一)。
|
|
145
214
|
*
|
|
@@ -160,6 +229,28 @@ export async function rollback(opts = {}) {
|
|
|
160
229
|
const archiveDir = join(this.memoryDir, 'archive')
|
|
161
230
|
await mkdir(archiveDir, { recursive: true })
|
|
162
231
|
|
|
232
|
+
/**
|
|
233
|
+
* **排他移动**:先 `link` 到目标(目标已存在 → `EEXIST`,绝不覆盖),成功后再删源。
|
|
234
|
+
*
|
|
235
|
+
* 为什么不能 `stat → rename`(复审复核实测构造出了覆盖):`rename` 在目标已存在时
|
|
236
|
+
* **静默覆盖**,而 `stat` 与 `rename` 之间正是别人(面板 / 模型工具的 `archiveMemory`,
|
|
237
|
+
* 它们不取 autodream 这把锁)可能刚把同名归档写进去的窗口 —— 与 R06② 同一类:
|
|
238
|
+
* 第二次系统调用没有存在性前提。而本函数的注释一直承诺「绝不覆盖」,代码却做不到。
|
|
239
|
+
*
|
|
240
|
+
* 平台不支持硬链接 / 跨卷时退回 `rename`(与 `memory/util.js` 那边的降级口径一致),
|
|
241
|
+
* 那是已知降级;NTFS / APFS / ext4 上走的是排他路径。
|
|
242
|
+
*/
|
|
243
|
+
const moveExclusive = async (from, to) => {
|
|
244
|
+
try {
|
|
245
|
+
await link(from, to)
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (err?.code === 'EEXIST') throw err
|
|
248
|
+
await rename(from, to)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
await rm(from, { force: true })
|
|
252
|
+
}
|
|
253
|
+
|
|
163
254
|
/**
|
|
164
255
|
* 移进 archive/(不删)。同名时**换一个退役名**落进去,绝不覆盖,也绝不放弃。
|
|
165
256
|
*
|
|
@@ -167,30 +258,36 @@ export async function rollback(opts = {}) {
|
|
|
167
258
|
* 归档区已经被本趟占着,回滚要让它从顶层退场时撞名 → 直接放弃 → 那条运行前根本
|
|
168
259
|
* 不存在的记忆继续活在顶层。目标状态是「顶层没有它」,撞名只是「原件名被占了」
|
|
169
260
|
* ——换个名字照样达到目标,且仍然一个文件都没删。
|
|
261
|
+
*
|
|
262
|
+
* 现在连「撞名判定」都不需要了:直接按「原名 → 退役名 → 再一个退役名」依次**排他落位**,
|
|
263
|
+
* `EEXIST` 就换下一个。判定与动作合成一步,窗口自然消失。
|
|
170
264
|
*/
|
|
171
265
|
const park = async (name, why) => {
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
266
|
+
const from = join(this.memoryDir, name)
|
|
267
|
+
const retiredName = () => `${name.replace(/\.md$/i, '')}.retired-${stamp}-${randomUUID().replace(/-/g, '').slice(0, 6)}.md`
|
|
268
|
+
for (const candidate of [name, retiredName(), retiredName()]) {
|
|
269
|
+
try {
|
|
270
|
+
await moveExclusive(from, join(archiveDir, candidate))
|
|
271
|
+
} catch (err) {
|
|
272
|
+
if (err?.code === 'EEXIST') continue
|
|
273
|
+
skipped.push(`${name}(本想${why},archive/ 里写不进去:${err?.message ?? err})`)
|
|
274
|
+
return false
|
|
275
|
+
}
|
|
276
|
+
parked.push(
|
|
277
|
+
candidate === name
|
|
278
|
+
? `${name}(${why})`
|
|
279
|
+
: `${name} → ${candidate}(${why};archive/ 已有同名,改用退役名,未删除)`,
|
|
280
|
+
)
|
|
177
281
|
return true
|
|
178
282
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
await rename(join(this.memoryDir, name), join(archiveDir, retired))
|
|
182
|
-
} catch (err) {
|
|
183
|
-
skipped.push(`${name}(本想${why},archive/ 已有同名、退役名也写不进去:${err?.message ?? err})`)
|
|
184
|
-
return false
|
|
185
|
-
}
|
|
186
|
-
parked.push(`${name} → ${retired}(${why};archive/ 已有同名,改用退役名,未删除)`)
|
|
187
|
-
return true
|
|
283
|
+
skipped.push(`${name}(本想${why},archive/ 里原名与两个退役名都被占了)`)
|
|
284
|
+
return false
|
|
188
285
|
}
|
|
189
286
|
|
|
190
287
|
if (scope === 'all') {
|
|
191
288
|
for (const name of snapNames) {
|
|
192
289
|
try {
|
|
193
|
-
await
|
|
290
|
+
await writeBack(name, name)
|
|
194
291
|
restored.push(name)
|
|
195
292
|
} catch (err) {
|
|
196
293
|
skipped.push(`${name}(写回失败:${err?.message ?? err})`)
|
|
@@ -243,7 +340,7 @@ export async function rollback(opts = {}) {
|
|
|
243
340
|
// 运行前存在:目标状态是「顶层是运行前那一版」。
|
|
244
341
|
if (atTop) {
|
|
245
342
|
try {
|
|
246
|
-
await
|
|
343
|
+
await writeBack(name, snapName)
|
|
247
344
|
restored.push(name)
|
|
248
345
|
} catch (err) {
|
|
249
346
|
skipped.push(`${name}(写回失败:${err?.message ?? err})`)
|
|
@@ -272,7 +369,7 @@ export async function rollback(opts = {}) {
|
|
|
272
369
|
else skipped.push(`${name}(恢复失败:${res.error})`)
|
|
273
370
|
} else if (snapContent) {
|
|
274
371
|
try {
|
|
275
|
-
await
|
|
372
|
+
await writeBack(name, snapName)
|
|
276
373
|
restored.push(name)
|
|
277
374
|
// ⚠️ 记进 notes 而**不是** skipped:文件确实恢复了(restored 里算一个),
|
|
278
375
|
// 只是没走「从 archive/ 移回」那条常规路径。计进 skipped 会让面板说
|
|
@@ -22,9 +22,11 @@ import { join, basename } from 'node:path'
|
|
|
22
22
|
*
|
|
23
23
|
* @param {string} id — 快照 id(apply 运行就是 runId;保护快照用 `pre-rollback-<时间戳>`)
|
|
24
24
|
* @param {'run'|'pre-rollback'} kind
|
|
25
|
+
* @param {{protect?:string[]}} [opts] — `protect` = 本轮轮转**不许淘汰**的快照目录名
|
|
26
|
+
* (回滚建保护快照时必须把它自己的源列进去,见 `pruneSnapshots` 的 R03 说明)
|
|
25
27
|
* @returns {Promise<{id:string, dir:string, count:number, expected:number, failed:string[], files:Array<{name:string,bytes:number,sha256:string}>}>}
|
|
26
28
|
*/
|
|
27
|
-
export async function snapshot(id, kind = 'run') {
|
|
29
|
+
export async function snapshot(id, kind = 'run', opts = {}) {
|
|
28
30
|
const dir = join(this.snapshotRoot, id)
|
|
29
31
|
await mkdir(this.snapshotRoot, { recursive: true })
|
|
30
32
|
// **故意不用 recursive**:快照 id 撞车必须是硬错误,不能静默覆盖一份已有的回滚点。
|
|
@@ -80,7 +82,7 @@ export async function snapshot(id, kind = 'run') {
|
|
|
80
82
|
failed,
|
|
81
83
|
}
|
|
82
84
|
await writeFile(join(dir, 'MANIFEST.json'), JSON.stringify(manifest, null, 2), 'utf8')
|
|
83
|
-
await this.pruneSnapshots()
|
|
85
|
+
await this.pruneSnapshots(opts?.protect ?? [])
|
|
84
86
|
return { id, dir, count: recorded.length, expected: files.length, failed, files: recorded }
|
|
85
87
|
}
|
|
86
88
|
|
|
@@ -90,13 +92,24 @@ export async function snapshot(id, kind = 'run') {
|
|
|
90
92
|
* **两类分开数**:普通运行的快照按 `maxSnapshotKeep`;「回滚前保护快照」
|
|
91
93
|
* 另给一个额度(`PRE_ROLLBACK_KEEP`)—— 保护快照的唯一用途就是「刚回滚完发现滚错了」,
|
|
92
94
|
* 被普通运行的快照轮转挤掉等于没有。旧版目录(`<stateRoot>/snapshots/`)只读,不参与淘汰。
|
|
95
|
+
*
|
|
96
|
+
* ⚠️ `protect` 是**豁免名单**(2026-10-12 复审 R03):回滚会先建一份保护快照,而
|
|
97
|
+
* `snapshot()` 末尾就调这里轮转 —— 若被选中的回滚源恰好是最旧的那份保护快照,
|
|
98
|
+
* 它会在**恢复动作开始之前**被自己这趟回滚删掉:随后源目录枚举为空,
|
|
99
|
+
* 回滚以 `ok:true / restored:0` 收场,而人以为自己退回去了。
|
|
100
|
+
* 「正在被使用的回滚点」必须排在淘汰之外。
|
|
101
|
+
*
|
|
102
|
+
* @param {string[]} [protect] — 本轮不许淘汰的快照目录名
|
|
93
103
|
*/
|
|
94
|
-
export async function pruneSnapshots() {
|
|
104
|
+
export async function pruneSnapshots(protect = []) {
|
|
95
105
|
const keep = Math.max(1, this.state.config.maxSnapshotKeep || DEFAULT_CONFIG.maxSnapshotKeep)
|
|
106
|
+
const keepNames = new Set((Array.isArray(protect) ? protect : []).filter((n) => typeof n === 'string' && n))
|
|
96
107
|
const entries = await readdir(this.snapshotRoot, { withFileTypes: true }).catch(() => [])
|
|
97
108
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort()
|
|
98
|
-
|
|
99
|
-
const
|
|
109
|
+
// 被豁免的那份先从轮转名单里摘出去:它不占额度,也不参与「谁最旧」的排序。
|
|
110
|
+
const eligible = dirs.filter((n) => !keepNames.has(n))
|
|
111
|
+
const preRollback = eligible.filter((n) => n.startsWith('pre-rollback-'))
|
|
112
|
+
const runs = eligible.filter((n) => !n.startsWith('pre-rollback-'))
|
|
100
113
|
for (const name of runs.slice(0, Math.max(0, runs.length - keep))) {
|
|
101
114
|
await rm(join(this.snapshotRoot, name), { recursive: true, force: true }).catch(() => {})
|
|
102
115
|
}
|
|
@@ -196,6 +209,39 @@ export async function verifySnapshot(dir, opts = {}) {
|
|
|
196
209
|
if (legacy) return unhashed('这是一份旧版回滚点(MANIFEST 里没有逐文件哈希),内容无法校验')
|
|
197
210
|
return broken('MANIFEST.json 里没有逐文件哈希清单(files 不是数组)—— 无法校验内容')
|
|
198
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* ⚠️ **清单自己声明过「这次没存全」时,校验必须直接拒绝**(2026-10-12 复审 R04)。
|
|
214
|
+
*
|
|
215
|
+
* `snapshot()` 把读失败的文件记进 `manifest.failed`,而这里原先只遍历 `files`(成功
|
|
216
|
+
* 存下来的那些)—— 一份 `failed:["a.md"]` 的快照于是能拿满分:`ok:true / checked:1`,
|
|
217
|
+
* 四个异常数组全空,回滚照走。
|
|
218
|
+
*
|
|
219
|
+
* 后果不是「少恢复一个文件」,而是**判据被换掉了**:整目录回滚会把 a.md 当成
|
|
220
|
+
* 「快照里本来就没有」→ 移进 `archive/`。一份已知残缺的快照被当成「当时那一版」,
|
|
221
|
+
* 而它恰恰不能代表那一版 —— 与「回滚点内容被外部改过就拒绝」是同一条原则,
|
|
222
|
+
* 只是这一条写在自己人留下的元数据里。
|
|
223
|
+
*
|
|
224
|
+
* 只认 `manifest.failed`:旧版快照没有这个字段(undefined → 空数组,不受影响)。
|
|
225
|
+
*/
|
|
226
|
+
const manifestFailed = Array.isArray(manifest?.failed)
|
|
227
|
+
? manifest.failed.filter((n) => typeof n === 'string' && n)
|
|
228
|
+
: []
|
|
229
|
+
if (manifestFailed.length) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
schema: 'hashed',
|
|
233
|
+
checked: 0,
|
|
234
|
+
missing: [],
|
|
235
|
+
tampered: [],
|
|
236
|
+
extra: [],
|
|
237
|
+
invalid: [],
|
|
238
|
+
manifestFailed,
|
|
239
|
+
reason:
|
|
240
|
+
`这份回滚点当初就没存全(${manifestFailed.length} 个文件捕获失败:` +
|
|
241
|
+
`${manifestFailed.slice(0, 3).join('、')}${manifestFailed.length > 3 ? '…' : ''})` +
|
|
242
|
+
'—— 它不能代表「当时那一版」',
|
|
243
|
+
}
|
|
244
|
+
}
|
|
199
245
|
|
|
200
246
|
const missing = []
|
|
201
247
|
const tampered = []
|
package/lib/autodream-audit.js
CHANGED
|
@@ -23,12 +23,23 @@
|
|
|
23
23
|
|
|
24
24
|
import { readdir, readFile } from 'node:fs/promises'
|
|
25
25
|
import { join } from 'node:path'
|
|
26
|
-
import { inspectMemoryEntry } from './memory/scan.js'
|
|
26
|
+
import { ARCHIVE_DIR_NAME, inspectMemoryEntry } from './memory/scan.js'
|
|
27
27
|
import { warnLog } from './memory/util.js'
|
|
28
28
|
|
|
29
29
|
/** 索引文件名。它本身不是一条记忆,是索引。 */
|
|
30
30
|
const INDEX_FILE = 'MEMORY.md'
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* 归档 / 恢复中途崩溃留下的**私有名残留**:`a.md.archiving-<hex>`。
|
|
34
|
+
*
|
|
35
|
+
* 为什么单列一类(2026-10-12 复审复核点出):`memory/archive.js` 把源 `rename` 到这个
|
|
36
|
+
* 私有名之后、复核完成之前若进程死掉,这份内容就留在了记忆目录里。它**不是** `.md`,
|
|
37
|
+
* 所以扫描 / 注入 / 星图 / 快照 / 列表都不认它(这本来是对的,免得半成品被当成一条记忆)——
|
|
38
|
+
* 但连体检也不认,就等于「可能装着用户唯一一份内容的东西」在界面上完全隐形,
|
|
39
|
+
* 而它躺在用户会同步、会备份的记忆根目录里。审计负责把它报出来(纯只读,只报不删)。
|
|
40
|
+
*/
|
|
41
|
+
const STRAY_RE = /\.archiving-[0-9a-f]+$/i
|
|
42
|
+
|
|
32
43
|
/**
|
|
33
44
|
* 审计**整个跳过**的文件(不读、不当被审对象)。
|
|
34
45
|
*
|
|
@@ -278,6 +289,18 @@ export async function auditMemoryDir(dir) {
|
|
|
278
289
|
}
|
|
279
290
|
}
|
|
280
291
|
|
|
292
|
+
/**
|
|
293
|
+
* 归档 / 恢复崩溃留下的私有名残留(只报不删)。顶层与归档区都扫:
|
|
294
|
+
* 归档动作的源在顶层,而人工移动 / 历史版本可能让残留在归档区里也出现。
|
|
295
|
+
*/
|
|
296
|
+
const strays = []
|
|
297
|
+
for (const sub of ['', ARCHIVE_DIR_NAME]) {
|
|
298
|
+
const list = await readdir(sub ? join(dir, sub) : dir, { withFileTypes: true }).catch(() => [])
|
|
299
|
+
for (const e of list) {
|
|
300
|
+
if (e.isFile() && STRAY_RE.test(e.name)) strays.push(sub ? `${sub}/${e.name}` : e.name)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
281
304
|
const problems =
|
|
282
305
|
dangling.length +
|
|
283
306
|
unlisted.length +
|
|
@@ -286,7 +309,8 @@ export async function auditMemoryDir(dir) {
|
|
|
286
309
|
brokenLinks.length +
|
|
287
310
|
hashHazards.length +
|
|
288
311
|
noFrontmatter.length +
|
|
289
|
-
unreadable.length
|
|
312
|
+
unreadable.length +
|
|
313
|
+
strays.length
|
|
290
314
|
|
|
291
315
|
return {
|
|
292
316
|
dir,
|
|
@@ -304,6 +328,7 @@ export async function auditMemoryDir(dir) {
|
|
|
304
328
|
hashHazards,
|
|
305
329
|
noFrontmatter: noFrontmatter.map((file) => ({ file })),
|
|
306
330
|
unreadable: unreadable.map((file) => ({ file })),
|
|
331
|
+
strays: strays.map((file) => ({ file })),
|
|
307
332
|
problems,
|
|
308
333
|
}
|
|
309
334
|
}
|
|
@@ -336,5 +361,12 @@ export function formatAudit(report, limit = 12) {
|
|
|
336
361
|
// 读不动的单列,且排最后:它不是「这条记忆写错了」,是「我没读到它」——
|
|
337
362
|
// 别让模型以为去改改 frontmatter 就能好。
|
|
338
363
|
section('读不动(权限/占用/编码,不参与上面各项检查)', report.unreadable ?? [], (r) => r.file)
|
|
364
|
+
// 归档残留:内容是**用户的**,只是文件名的形状不对。要提醒人去看,而不是让模型当成一条
|
|
365
|
+
// 记忆去整理 —— 所以写明处置方式(改名回 `.md` 或删掉),并且它不计入「记忆写错了」那类。
|
|
366
|
+
section(
|
|
367
|
+
'归档中途留下的残留(内容可能还在,请人工确认后改回 .md 或删除)',
|
|
368
|
+
report.strays ?? [],
|
|
369
|
+
(r) => r.file,
|
|
370
|
+
)
|
|
339
371
|
return lines.join('\n')
|
|
340
372
|
}
|
package/lib/autodream-tools.js
CHANGED
|
@@ -29,6 +29,7 @@ import { join, basename } from 'node:path'
|
|
|
29
29
|
import { createHash } from 'node:crypto'
|
|
30
30
|
import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './autodream-audit.js'
|
|
31
31
|
import { archiveMemory } from './memory/archive.js'
|
|
32
|
+
import { MAX_FILE_BYTES } from './memory/config.js'
|
|
32
33
|
import { writeFileAtomic, warnLog } from './memory/util.js'
|
|
33
34
|
import { inspectMemoryEntry, reservedIdentitySet } from './memory/scan.js'
|
|
34
35
|
|
|
@@ -556,7 +557,18 @@ export function createToolRunner(opts) {
|
|
|
556
557
|
if (!/^---\r?\n/.test(a.content)) {
|
|
557
558
|
return '错误:内容必须以 `---` 开头的 YAML frontmatter 起始。参考 list_memory 里已有文件的写法。'
|
|
558
559
|
}
|
|
559
|
-
|
|
560
|
+
/**
|
|
561
|
+
* ⚠️ 体积闸门必须按**字节**算,与读取端(`inspectMemoryEntry` 的 `info.size`)同一把尺子。
|
|
562
|
+
*
|
|
563
|
+
* 原先按 `content.length`(UTF-16 码元数)判:18 万个汉字 = 18 万「字符」、
|
|
564
|
+
* 却是 54 万字节 —— 工具高高兴兴回「已写入」,紧接着这条记忆就被读端的
|
|
565
|
+
* 512 KiB 闸门判成 `too-big` 拒绝。**自己写出自己读不了的文件**,
|
|
566
|
+
* 而模型收到的是一句成功。多字节文本(中文正是常态)下两把尺子差了 3 倍。
|
|
567
|
+
*/
|
|
568
|
+
const bytes = Buffer.byteLength(a.content, 'utf8')
|
|
569
|
+
if (bytes > MAX_FILE_BYTES) {
|
|
570
|
+
return `错误:内容过大(${bytes} 字节,上限 ${MAX_FILE_BYTES} 字节 / ${a.content.length} 字符)。请拆成多条记忆,或只保留要点。`
|
|
571
|
+
}
|
|
560
572
|
const reason = typeof a.reason === 'string' ? a.reason.trim() : ''
|
|
561
573
|
const selfReported = reason.length >= MIN_REASON_CHARS
|
|
562
574
|
const before = await fileFingerprint(full)
|
|
@@ -606,6 +618,15 @@ export function createToolRunner(opts) {
|
|
|
606
618
|
touched.push(`${basename(full)} → archive/`)
|
|
607
619
|
listCache = null
|
|
608
620
|
auditCache = null
|
|
621
|
+
/**
|
|
622
|
+
* 归档成功、索引却没摘掉时**必须说出来**(复审 R11 的配套):索引会留下一条
|
|
623
|
+
* 指向「已不在顶层」的悬空条目,下次体检报问题,而模型只看到一句「已归档」时
|
|
624
|
+
* 完全无从修正。这类「说了要做就得做到」的出口,缺一个都等于没修。
|
|
625
|
+
*/
|
|
626
|
+
if (res.indexError) {
|
|
627
|
+
warnings.push(`${basename(full)} 的索引没摘掉:${res.indexError}`)
|
|
628
|
+
return `已归档 ${basename(full)}(原因:${reason})。⚠ 但索引里的那一行没能摘掉(${res.indexError})—— 这条记忆已不在顶层,索引会显示为悬空条目,需要人工处理。`
|
|
629
|
+
}
|
|
609
630
|
return `已归档 ${basename(full)}(原因:${reason})。`
|
|
610
631
|
}
|
|
611
632
|
default:
|