dsh-recall-plugin 1.6.1 → 1.6.2
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/CHANGELOG.md +9 -0
- package/README.md +4 -3
- package/lib/client.js +38 -9
- package/lib/maintenance.js +145 -145
- package/lib/scripts.posix.js +12 -0
- package/lib/scripts.pwsh.js +16 -0
- package/lib/snapshots.js +258 -216
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
|
4
4
|
|
|
5
|
+
## [1.6.2] - 2026-08-25
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- 用户消息图片显示空白且刷新无效([#8](https://github.com/limbo947/dsh-recall-plugin/issues/8)):会话回放时最早的图片消息先于会话 binding 就绪渲染,`loadImage` 以 unknown session 拒绝后被静默 `catch` 吞掉,图片永久空白。图片加载改为 400ms/1.5s/4s 三次退避自动重试(暂态失败自愈),耗尽后显示「图片加载失败,点击重试」失败态(对齐官方 MessageImage 语义);顺带修复 attachment 切换时短暂残留上一张图的问题。
|
|
10
|
+
- 快照连续失败无限累积磁盘残骸([#7](https://github.com/limbo947/dsh-recall-plugin/issues/7) 评论实测一个下午 127GB dangling 对象):失败重试的每次 `git add` 都会写入无 tag 可达的残骸对象。两道防线——
|
|
11
|
+
- **失败清理**:每次快照失败后执行 `git prune`(以 refs + 暂存 index 为根做可达性删除),只清当次残骸、不碰任何 tag 快照,已暂存对象保留以维持下次增量。
|
|
12
|
+
- **熔断退避**:连续 3 次失败后按 5min 起步指数翻倍、60min 封顶的退避跳过快照,冷却期满自动重试,成功一次即全部复位;进入熔断只在跳变沿记一条最近错误。
|
|
13
|
+
|
|
5
14
|
## [1.6.1] - 2026-08-23
|
|
6
15
|
|
|
7
16
|
### 新增
|
package/README.md
CHANGED
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
**在任意一条你发过的消息下方**,**点「↶ 撤回」**,**工作区文件和对话历史一起回到那条消息发出之前的状态**。
|
|
15
|
+
---
|
|
15
16
|
|
|
17
|
+
[更新日志](CHANGELOG.md)
|
|
16
18
|
## 界面预览
|
|
19
|
+
|
|
17
20
|
- 撤回按钮位置
|
|
18
21
|
|
|
19
22
|

|
|
@@ -23,7 +26,7 @@
|
|
|
23
26
|
| --- | --- |
|
|
24
27
|
|  |  |
|
|
25
28
|
|
|
26
|
-
- 撤回后自动把消息文本回填到输入框,方便修改后重发(可在设置卡片关闭)
|
|
29
|
+
- 撤回后自动把消息文本回填到输入框,方便修改后重发(可在设置卡片关闭)
|
|
27
30
|
- 设置页 · 插件配置卡片(阈值 / 排除表 / 快照管理,保存即热生效)
|
|
28
31
|
|
|
29
32
|

|
|
@@ -126,6 +129,4 @@ Copy-Item -Recurse -Force $pkg "$profile\node_modules\dsh-recall-plugin"
|
|
|
126
129
|
|
|
127
130
|
MIT
|
|
128
131
|
|
|
129
|
-
---
|
|
130
132
|
|
|
131
|
-
[更新日志](CHANGELOG.md)
|
package/lib/client.js
CHANGED
|
@@ -39,6 +39,8 @@ window.__ModuleLoader__.load({
|
|
|
39
39
|
'.dsh-recall-stack{flex-direction:column;align-items:flex-end;gap:8px;min-width:0;max-width:min(525px,82%);display:flex}',
|
|
40
40
|
'.dsh-recall-bubble{background:var(--dsw-specific-bubble);max-width:100%;color:var(--dsw-alias-label-primary);border-radius:22px;padding:10px 16px;font-size:16px;line-height:24px;white-space:pre-wrap;word-break:break-word}',
|
|
41
41
|
'.dsh-recall-img{max-width:100%;max-height:320px;border-radius:12px;object-fit:contain}',
|
|
42
|
+
'.dsh-recall-img-failed{border:1px dashed var(--dsw-alias-border-l2);border-radius:12px;padding:8px 14px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0}',
|
|
43
|
+
'.dsh-recall-img-failed:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}',
|
|
42
44
|
'.dsh-recall-json{margin:0;max-width:100%;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);white-space:pre-wrap;word-break:break-word;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:8px 10px;background:var(--dsw-alias-markdown-code-block)}',
|
|
43
45
|
'.dsh-recall-actions{align-items:center;gap:10px;height:28px;display:flex}',
|
|
44
46
|
'.dsh-recall-time{color:var(--dsw-alias-label-tertiary);white-space:nowrap;padding-right:12px;font-size:14px;line-height:24px}',
|
|
@@ -247,23 +249,50 @@ window.__ModuleLoader__.load({
|
|
|
247
249
|
)
|
|
248
250
|
}
|
|
249
251
|
|
|
252
|
+
// 图片加载失败的暂态判定(issue #8):会话回放时最早的图片消息先于
|
|
253
|
+
// 会话 binding 就绪渲染,loadImage 以 unknown session 拒绝——这类
|
|
254
|
+
// 失败靠短退避自动重试即可自愈;此前 `.catch(() => {})` 把失败静默
|
|
255
|
+
// 吞掉且 src 恒为 null,图片永久空白且刷新无法恢复。自动重试耗尽
|
|
256
|
+
// 后置 failed,由 ImageBox 提供「点击重试」(对齐官方 MessageImage
|
|
257
|
+
// 的失败态语义),attempt 仅被手动重试递增以复用整条加载链。
|
|
258
|
+
const IMAGE_RETRY_DELAYS = [400, 1500, 4000]
|
|
259
|
+
|
|
250
260
|
function useImageSrc(attachment, loadImage) {
|
|
251
261
|
const [src, setSrc] = React.useState(null)
|
|
262
|
+
const [failed, setFailed] = React.useState(false)
|
|
263
|
+
const [attempt, setAttempt] = React.useState(0)
|
|
252
264
|
React.useEffect(() => {
|
|
253
265
|
let alive = true
|
|
266
|
+
let timer = null
|
|
254
267
|
if (!attachment || typeof loadImage !== 'function') return undefined
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
268
|
+
// 复位旧状态:attachment 变化或手动重试时清掉上一轮的 src/failed,
|
|
269
|
+
// 避免加载期间短暂显示上一张图或残留的失败态
|
|
270
|
+
setSrc(null)
|
|
271
|
+
setFailed(false)
|
|
272
|
+
const tryLoad = (retried) => {
|
|
273
|
+
Promise.resolve(loadImage(attachment)).then((url) => {
|
|
274
|
+
if (!alive) return
|
|
275
|
+
if (url) setSrc(String(url))
|
|
276
|
+
else if (retried < IMAGE_RETRY_DELAYS.length) timer = setTimeout(() => tryLoad(retried + 1), IMAGE_RETRY_DELAYS[retried])
|
|
277
|
+
else setFailed(true)
|
|
278
|
+
}).catch(() => {
|
|
279
|
+
if (!alive) return
|
|
280
|
+
if (retried < IMAGE_RETRY_DELAYS.length) timer = setTimeout(() => tryLoad(retried + 1), IMAGE_RETRY_DELAYS[retried])
|
|
281
|
+
else setFailed(true)
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
tryLoad(0)
|
|
285
|
+
return () => { alive = false; if (timer) clearTimeout(timer) }
|
|
286
|
+
}, [attachment, loadImage, attempt])
|
|
287
|
+
return { src, failed, retry: () => setAttempt(attempt + 1) }
|
|
261
288
|
}
|
|
262
289
|
|
|
263
290
|
function ImageBox(props) {
|
|
264
|
-
const src = useImageSrc(props.attachment, props.loadImage)
|
|
265
|
-
if (
|
|
266
|
-
|
|
291
|
+
const { src, failed, retry } = useImageSrc(props.attachment, props.loadImage)
|
|
292
|
+
if (src) return React.createElement('img', { className: 'dsh-recall-img', src, alt: '' })
|
|
293
|
+
// 加载中保持原零占位语义(不撑气泡);只在确定失败后渲染可重试入口
|
|
294
|
+
if (failed) return React.createElement('button', { type: 'button', className: 'dsh-recall-img-failed', onClick: retry }, '图片加载失败,点击重试')
|
|
295
|
+
return null
|
|
267
296
|
}
|
|
268
297
|
|
|
269
298
|
// kind 语义单表承载(文案/徽章类名/汇总顺序):新增 kind 时只改
|
package/lib/maintenance.js
CHANGED
|
@@ -1,145 +1,145 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-recall-plugin — 快照维护(ctx 绑定的工厂,无模块级副作用)
|
|
3
|
-
*
|
|
4
|
-
* 职责:磁盘占用治理,两件事——
|
|
5
|
-
* 1. 定期 git gc:全量保留策略下把 loose 对象压 pack + 跨版本 delta,
|
|
6
|
-
* 无损(所有 tag 可达对象一个不丢),通常省一半以上空间;
|
|
7
|
-
* 2. 会话删除联动清理:会话日志已从磁盘消失时,删除该会话全部快照 tag
|
|
8
|
-
* 并重写索引,空间由紧随的同一次 gc --prune=now 真正释放。
|
|
9
|
-
*
|
|
10
|
-
* 触发点在每条用户消息快照之后的同一条串行队列里(见 index.js 的事件
|
|
11
|
-
* 接线),因此 gc/清理与快照天然互斥,不存在 git 锁竞态。
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
// gc 节流阈值来自 config 域(设置页「插件配置」卡片可实时改写 cfg,
|
|
15
|
-
// 因此这里按调用时取值而不是工厂创建时快照;环境变量
|
|
16
|
-
// DSH_RECALL_GC_SNAPS/GC_HOURS 仍最高优先,见 config.js):
|
|
17
|
-
// 每 gcSnaps 条快照或距上次 gc gcHours 小时,先到先触发。默认「50 条或
|
|
18
|
-
// 24 小时」——重活(gc)一天至多一次的量级,轻会话用户也不会等太久。
|
|
19
|
-
export function createMaintenance(ctx, rt, snaps, config) {
|
|
20
|
-
const sessions = ctx.sessions
|
|
21
|
-
const state = rt.state
|
|
22
|
-
// 平台选择的脚本模板(gc/purge 两套模板同名导出)
|
|
23
|
-
const S = rt.scripts
|
|
24
|
-
|
|
25
|
-
// 删除一个会话的全部快照:按 root 分组(同一会话可能换过工作目录),
|
|
26
|
-
// tag 分块删除规避命令行长度上限,索引重写交给 snaps.saveIndex。
|
|
27
|
-
// best-effort:单块失败只记日志,剩余块继续;tag 残留由下次清理幂等收尾。
|
|
28
|
-
async function purgeSession(sessionId) {
|
|
29
|
-
const byRoot = new Map()
|
|
30
|
-
for (const [id, s] of state.snapshots.entries()) {
|
|
31
|
-
if (!s || s.sessionId !== sessionId) continue
|
|
32
|
-
if (!byRoot.has(s.root)) byRoot.set(s.root, [])
|
|
33
|
-
byRoot.get(s.root).push(id)
|
|
34
|
-
}
|
|
35
|
-
let purged = 0
|
|
36
|
-
for (const [root, ids] of byRoot) {
|
|
37
|
-
let store = state.stores.get(root)
|
|
38
|
-
if (!store) {
|
|
39
|
-
// 冷启动时 store 缓存可能还没建:现场解析一次而不是直接跳过——
|
|
40
|
-
// 跳过会让该 root 的快照永远清不掉(sweep 每轮都 miss)
|
|
41
|
-
try { store = await rt.resolveStore(root) } catch (error) { store = null }
|
|
42
|
-
}
|
|
43
|
-
if (!store || !state.gitExe) continue
|
|
44
|
-
try {
|
|
45
|
-
for (let i = 0; i < ids.length; i += 100) {
|
|
46
|
-
await rt.runShell(S.purgeTagsScript(store, state.gitExe, ids.slice(i, i + 100).map((id) => 'snap-' + id)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
|
|
47
|
-
}
|
|
48
|
-
for (const id of ids) state.snapshots.delete(id)
|
|
49
|
-
await snaps.saveIndex(root, sessionId)
|
|
50
|
-
purged += ids.length
|
|
51
|
-
} catch (error) {
|
|
52
|
-
rt.recordError('recall purge session failed: ' + String(error))
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
if (purged > 0) console.error('recall purged snapshots of deleted session:', sessionId, purged)
|
|
56
|
-
return purged
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// 扫描索引里出现过的全部会话:既不在 sessions 注册表、冷读日志又失败的,
|
|
60
|
-
// 才认定「已删除」。两个保守闸门:
|
|
61
|
-
// - sessionQuery 服务不存在时整体跳过——没有冷读能力就无法区分
|
|
62
|
-
// 「已删除」和「只是冷着」,误删快照不可逆,宁可不清理;
|
|
63
|
-
// - 归档会话(撤回功能自己归档的)日志仍在磁盘上,readSession 仍成功,
|
|
64
|
-
// 不会被误清——只有日志真正消失才触发。
|
|
65
|
-
async function sweepDeletedSessions() {
|
|
66
|
-
const ids = new Set()
|
|
67
|
-
for (const s of state.snapshots.values()) {
|
|
68
|
-
if (s && s.sessionId) ids.add(s.sessionId)
|
|
69
|
-
}
|
|
70
|
-
if (!ids.size) return
|
|
71
|
-
const query = ctx.get('sessionQuery')
|
|
72
|
-
if (!query || typeof query.readSession !== 'function') return
|
|
73
|
-
for (const id of ids) {
|
|
74
|
-
if (sessions.get(id)) continue
|
|
75
|
-
let alive = false
|
|
76
|
-
try {
|
|
77
|
-
const log = await query.readSession(id)
|
|
78
|
-
alive = Boolean(log)
|
|
79
|
-
} catch (error) {
|
|
80
|
-
alive = false
|
|
81
|
-
}
|
|
82
|
-
if (!alive) await purgeSession(id)
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// 维护核心(节流判定 + 清理 + gc):force 供设置页「立即 gc」手动触发,
|
|
87
|
-
// 跳过阈值检查但仍走同一条串行队列调用方——与快照天然互斥的约束不变。
|
|
88
|
-
// 失败也推进 gcLastAt:gc 失败往往是环境性的(磁盘/杀软),不推进时间戳
|
|
89
|
-
// 会让后续每条消息都重试一次重量级 gc,把队列堵住。
|
|
90
|
-
async function runGc(sessionId, force) {
|
|
91
|
-
const root = await rt.resolveRoot(sessionId)
|
|
92
|
-
if (!root) return false
|
|
93
|
-
const store = state.stores.get(root)
|
|
94
|
-
if (!store || !state.gitExe) return false
|
|
95
|
-
const now = Date.now()
|
|
96
|
-
const last = state.gcLastAt.get(store.git) || 0
|
|
97
|
-
const count = (state.gcCount.get(store.git) || 0) + 1
|
|
98
|
-
state.gcCount.set(store.git, count)
|
|
99
|
-
if (!force && count < config.gcSnaps && now - last < config.gcHours * 3600000) return false
|
|
100
|
-
state.gcCount.set(store.git, 0)
|
|
101
|
-
try {
|
|
102
|
-
await sweepDeletedSessions()
|
|
103
|
-
await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
|
|
104
|
-
} catch (error) {
|
|
105
|
-
rt.recordError('recall maintenance failed: ' + String(error))
|
|
106
|
-
}
|
|
107
|
-
state.gcLastAt.set(store.git, Date.now())
|
|
108
|
-
return true
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// 全局 gc(设置卡片没有会话上下文):清理扫描一次 + 逐 store gc。
|
|
112
|
-
// store 全集取内存缓存(启动预热与历次操作会填齐已知工作区);逐个
|
|
113
|
-
// best-effort,单个失败记错误继续。调用方(manage 端点)把它排进同一条
|
|
114
|
-
// 串行队列,与快照天然互斥,无 git 锁竞态。
|
|
115
|
-
async function runGcAll() {
|
|
116
|
-
const stores = Array.from(new Set(Array.from(state.stores.values()).filter(Boolean)))
|
|
117
|
-
if (!stores.length || !state.gitExe) return false
|
|
118
|
-
try {
|
|
119
|
-
await sweepDeletedSessions()
|
|
120
|
-
} catch (error) {
|
|
121
|
-
rt.recordError('recall sweep failed: ' + String(error))
|
|
122
|
-
}
|
|
123
|
-
let done = 0
|
|
124
|
-
for (const store of stores) {
|
|
125
|
-
try {
|
|
126
|
-
await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
|
|
127
|
-
done++
|
|
128
|
-
} catch (error) {
|
|
129
|
-
rt.recordError('recall gc failed for ' + (store && store.git) + ': ' + String(error))
|
|
130
|
-
}
|
|
131
|
-
state.gcLastAt.set(store.git, Date.now())
|
|
132
|
-
state.gcCount.set(store.git, 0)
|
|
133
|
-
}
|
|
134
|
-
return true
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// 每条消息快照后串行调用(见 index.js 事件接线)
|
|
138
|
-
async function maybeMaintain(sessionId) {
|
|
139
|
-
await runGc(sessionId, false)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// 模块收敛:只导出被装配层消费的入口;sweepDeletedSessions /
|
|
143
|
-
// purgeSession 是 gc 的内部步骤,不对外暴露面
|
|
144
|
-
return { maybeMaintain, runGc, runGcAll }
|
|
145
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dsh-recall-plugin — 快照维护(ctx 绑定的工厂,无模块级副作用)
|
|
3
|
+
*
|
|
4
|
+
* 职责:磁盘占用治理,两件事——
|
|
5
|
+
* 1. 定期 git gc:全量保留策略下把 loose 对象压 pack + 跨版本 delta,
|
|
6
|
+
* 无损(所有 tag 可达对象一个不丢),通常省一半以上空间;
|
|
7
|
+
* 2. 会话删除联动清理:会话日志已从磁盘消失时,删除该会话全部快照 tag
|
|
8
|
+
* 并重写索引,空间由紧随的同一次 gc --prune=now 真正释放。
|
|
9
|
+
*
|
|
10
|
+
* 触发点在每条用户消息快照之后的同一条串行队列里(见 index.js 的事件
|
|
11
|
+
* 接线),因此 gc/清理与快照天然互斥,不存在 git 锁竞态。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// gc 节流阈值来自 config 域(设置页「插件配置」卡片可实时改写 cfg,
|
|
15
|
+
// 因此这里按调用时取值而不是工厂创建时快照;环境变量
|
|
16
|
+
// DSH_RECALL_GC_SNAPS/GC_HOURS 仍最高优先,见 config.js):
|
|
17
|
+
// 每 gcSnaps 条快照或距上次 gc gcHours 小时,先到先触发。默认「50 条或
|
|
18
|
+
// 24 小时」——重活(gc)一天至多一次的量级,轻会话用户也不会等太久。
|
|
19
|
+
export function createMaintenance(ctx, rt, snaps, config) {
|
|
20
|
+
const sessions = ctx.sessions
|
|
21
|
+
const state = rt.state
|
|
22
|
+
// 平台选择的脚本模板(gc/purge 两套模板同名导出)
|
|
23
|
+
const S = rt.scripts
|
|
24
|
+
|
|
25
|
+
// 删除一个会话的全部快照:按 root 分组(同一会话可能换过工作目录),
|
|
26
|
+
// tag 分块删除规避命令行长度上限,索引重写交给 snaps.saveIndex。
|
|
27
|
+
// best-effort:单块失败只记日志,剩余块继续;tag 残留由下次清理幂等收尾。
|
|
28
|
+
async function purgeSession(sessionId) {
|
|
29
|
+
const byRoot = new Map()
|
|
30
|
+
for (const [id, s] of state.snapshots.entries()) {
|
|
31
|
+
if (!s || s.sessionId !== sessionId) continue
|
|
32
|
+
if (!byRoot.has(s.root)) byRoot.set(s.root, [])
|
|
33
|
+
byRoot.get(s.root).push(id)
|
|
34
|
+
}
|
|
35
|
+
let purged = 0
|
|
36
|
+
for (const [root, ids] of byRoot) {
|
|
37
|
+
let store = state.stores.get(root)
|
|
38
|
+
if (!store) {
|
|
39
|
+
// 冷启动时 store 缓存可能还没建:现场解析一次而不是直接跳过——
|
|
40
|
+
// 跳过会让该 root 的快照永远清不掉(sweep 每轮都 miss)
|
|
41
|
+
try { store = await rt.resolveStore(root) } catch (error) { store = null }
|
|
42
|
+
}
|
|
43
|
+
if (!store || !state.gitExe) continue
|
|
44
|
+
try {
|
|
45
|
+
for (let i = 0; i < ids.length; i += 100) {
|
|
46
|
+
await rt.runShell(S.purgeTagsScript(store, state.gitExe, ids.slice(i, i + 100).map((id) => 'snap-' + id)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
|
|
47
|
+
}
|
|
48
|
+
for (const id of ids) state.snapshots.delete(id)
|
|
49
|
+
await snaps.saveIndex(root, sessionId)
|
|
50
|
+
purged += ids.length
|
|
51
|
+
} catch (error) {
|
|
52
|
+
rt.recordError('recall purge session failed: ' + String(error))
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (purged > 0) console.error('recall purged snapshots of deleted session:', sessionId, purged)
|
|
56
|
+
return purged
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 扫描索引里出现过的全部会话:既不在 sessions 注册表、冷读日志又失败的,
|
|
60
|
+
// 才认定「已删除」。两个保守闸门:
|
|
61
|
+
// - sessionQuery 服务不存在时整体跳过——没有冷读能力就无法区分
|
|
62
|
+
// 「已删除」和「只是冷着」,误删快照不可逆,宁可不清理;
|
|
63
|
+
// - 归档会话(撤回功能自己归档的)日志仍在磁盘上,readSession 仍成功,
|
|
64
|
+
// 不会被误清——只有日志真正消失才触发。
|
|
65
|
+
async function sweepDeletedSessions() {
|
|
66
|
+
const ids = new Set()
|
|
67
|
+
for (const s of state.snapshots.values()) {
|
|
68
|
+
if (s && s.sessionId) ids.add(s.sessionId)
|
|
69
|
+
}
|
|
70
|
+
if (!ids.size) return
|
|
71
|
+
const query = ctx.get('sessionQuery')
|
|
72
|
+
if (!query || typeof query.readSession !== 'function') return
|
|
73
|
+
for (const id of ids) {
|
|
74
|
+
if (sessions.get(id)) continue
|
|
75
|
+
let alive = false
|
|
76
|
+
try {
|
|
77
|
+
const log = await query.readSession(id)
|
|
78
|
+
alive = Boolean(log)
|
|
79
|
+
} catch (error) {
|
|
80
|
+
alive = false
|
|
81
|
+
}
|
|
82
|
+
if (!alive) await purgeSession(id)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 维护核心(节流判定 + 清理 + gc):force 供设置页「立即 gc」手动触发,
|
|
87
|
+
// 跳过阈值检查但仍走同一条串行队列调用方——与快照天然互斥的约束不变。
|
|
88
|
+
// 失败也推进 gcLastAt:gc 失败往往是环境性的(磁盘/杀软),不推进时间戳
|
|
89
|
+
// 会让后续每条消息都重试一次重量级 gc,把队列堵住。
|
|
90
|
+
async function runGc(sessionId, force) {
|
|
91
|
+
const root = await rt.resolveRoot(sessionId)
|
|
92
|
+
if (!root) return false
|
|
93
|
+
const store = state.stores.get(root)
|
|
94
|
+
if (!store || !state.gitExe) return false
|
|
95
|
+
const now = Date.now()
|
|
96
|
+
const last = state.gcLastAt.get(store.git) || 0
|
|
97
|
+
const count = (state.gcCount.get(store.git) || 0) + 1
|
|
98
|
+
state.gcCount.set(store.git, count)
|
|
99
|
+
if (!force && count < config.gcSnaps && now - last < config.gcHours * 3600000) return false
|
|
100
|
+
state.gcCount.set(store.git, 0)
|
|
101
|
+
try {
|
|
102
|
+
await sweepDeletedSessions()
|
|
103
|
+
await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
|
|
104
|
+
} catch (error) {
|
|
105
|
+
rt.recordError('recall maintenance failed: ' + String(error))
|
|
106
|
+
}
|
|
107
|
+
state.gcLastAt.set(store.git, Date.now())
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 全局 gc(设置卡片没有会话上下文):清理扫描一次 + 逐 store gc。
|
|
112
|
+
// store 全集取内存缓存(启动预热与历次操作会填齐已知工作区);逐个
|
|
113
|
+
// best-effort,单个失败记错误继续。调用方(manage 端点)把它排进同一条
|
|
114
|
+
// 串行队列,与快照天然互斥,无 git 锁竞态。
|
|
115
|
+
async function runGcAll() {
|
|
116
|
+
const stores = Array.from(new Set(Array.from(state.stores.values()).filter(Boolean)))
|
|
117
|
+
if (!stores.length || !state.gitExe) return false
|
|
118
|
+
try {
|
|
119
|
+
await sweepDeletedSessions()
|
|
120
|
+
} catch (error) {
|
|
121
|
+
rt.recordError('recall sweep failed: ' + String(error))
|
|
122
|
+
}
|
|
123
|
+
let done = 0
|
|
124
|
+
for (const store of stores) {
|
|
125
|
+
try {
|
|
126
|
+
await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
|
|
127
|
+
done++
|
|
128
|
+
} catch (error) {
|
|
129
|
+
rt.recordError('recall gc failed for ' + (store && store.git) + ': ' + String(error))
|
|
130
|
+
}
|
|
131
|
+
state.gcLastAt.set(store.git, Date.now())
|
|
132
|
+
state.gcCount.set(store.git, 0)
|
|
133
|
+
}
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 每条消息快照后串行调用(见 index.js 事件接线)
|
|
138
|
+
async function maybeMaintain(sessionId) {
|
|
139
|
+
await runGc(sessionId, false)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 模块收敛:只导出被装配层消费的入口;sweepDeletedSessions /
|
|
143
|
+
// purgeSession 是 gc 的内部步骤,不对外暴露面
|
|
144
|
+
return { maybeMaintain, runGc, runGcAll }
|
|
145
|
+
}
|
package/lib/scripts.posix.js
CHANGED
|
@@ -279,6 +279,18 @@ export function gcScript(store, gitExe) {
|
|
|
279
279
|
].join('\n')
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
// 快照失败后的残骸清理(语义同 pwsh 版,动机详见其注释):prune 以
|
|
283
|
+
// refs + 暂存 index 为根删无引用对象,只清失败 add 的残骸、不碰 tag 快照
|
|
284
|
+
export function pruneScript(store, gitExe) {
|
|
285
|
+
return [
|
|
286
|
+
'set -e',
|
|
287
|
+
'git=' + psq(gitExe),
|
|
288
|
+
'g=' + psq(store.git),
|
|
289
|
+
'"$git" --git-dir="$g" prune',
|
|
290
|
+
'echo PRUNE_OK'
|
|
291
|
+
].join('\n')
|
|
292
|
+
}
|
|
293
|
+
|
|
282
294
|
// 删除指定快照 tag(会话已删联动清理用):tag -d 对不存在 tag 非零退出,
|
|
283
295
|
// || true 吞掉——best-effort,残留 tag 由下次清理幂等收尾(同 pwsh 版)
|
|
284
296
|
export function purgeTagsScript(store, gitExe, tags) {
|
package/lib/scripts.pwsh.js
CHANGED
|
@@ -314,6 +314,22 @@ export function gcScript(store, gitExe) {
|
|
|
314
314
|
].join('\n')
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
// 快照失败后的残骸清理(issue #7 实测:失败重试一个下午可积累 127GB
|
|
318
|
+
// dangling 对象)。失败的 add 已把部分 blob 写进对象库,但 write-tree/
|
|
319
|
+
// commit/tag 未发生——这些对象无引用可达;git prune 以 refs + 暂存 index
|
|
320
|
+
// 为根做可达性删除,正好只清掉这批无主对象,不碰任何 tag 快照。不做 gc:
|
|
321
|
+
// gc 是全量 repack 重活,失败重试场景下对象库往往已被残骸撑大,代价过高。
|
|
322
|
+
// 调用点在 captureSnapshot 的 catch 里,与快照同走一条串行队列,无锁竞态。
|
|
323
|
+
export function pruneScript(store, gitExe) {
|
|
324
|
+
return [
|
|
325
|
+
"$ErrorActionPreference = 'Stop'",
|
|
326
|
+
'$git = ' + psq(gitExe),
|
|
327
|
+
'$g = ' + psq(store.git),
|
|
328
|
+
'& $git --git-dir=$g prune',
|
|
329
|
+
"Write-Output 'PRUNE_OK'"
|
|
330
|
+
].join('\n')
|
|
331
|
+
}
|
|
332
|
+
|
|
317
333
|
// 删除指定快照 tag(会话已删联动清理用)。best-effort:个别 tag 已不存在时
|
|
318
334
|
// git 非零退出,但其余 tag 已被删除——所以显式 exit 0 吞掉退出码,
|
|
319
335
|
// 残留的由下一次清理幂等地收尾;JS 侧无论脚本结果都会同步索引。
|
package/lib/snapshots.js
CHANGED
|
@@ -1,216 +1,258 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-recall-plugin — 快照域(ctx 绑定的工厂,无模块级副作用)
|
|
3
|
-
*
|
|
4
|
-
* 职责:快照捕获(captureSnapshot)、索引落盘/载入/孤儿重建、
|
|
5
|
-
* diff 清单(diffFor)、回退执行(rollbackFor)、会话切点解析
|
|
6
|
-
* (resolveCutSeq)。依赖 store.js 的执行与存储层,脚本文本全部
|
|
7
|
-
* 来自 rt.scripts(按平台选择的 scripts.pwsh.js / scripts.posix.js)。
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export function createSnapshots(ctx, rt, config) {
|
|
11
|
-
const sessions = ctx.sessions
|
|
12
|
-
const state = rt.state
|
|
13
|
-
// 平台选择的脚本模板(rt.scripts = scripts.pwsh.js / scripts.posix.js):
|
|
14
|
-
// 两套导出同名接口但实现分属 pwsh/bash,所有调用统一走 S.*
|
|
15
|
-
const S = rt.scripts
|
|
16
|
-
// 基础排除表随调用透传给脚本模板(用户 config 可调,即时生效)
|
|
17
|
-
// 运行时读取而非创建时快照:设置卡片热更新 baseExcludes 后,
|
|
18
|
-
// 下一次快照/diff/回退立即按新排除表执行
|
|
19
|
-
const BASE = () => config.baseExcludes
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
await rt.
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-recall-plugin — 快照域(ctx 绑定的工厂,无模块级副作用)
|
|
3
|
+
*
|
|
4
|
+
* 职责:快照捕获(captureSnapshot)、索引落盘/载入/孤儿重建、
|
|
5
|
+
* diff 清单(diffFor)、回退执行(rollbackFor)、会话切点解析
|
|
6
|
+
* (resolveCutSeq)。依赖 store.js 的执行与存储层,脚本文本全部
|
|
7
|
+
* 来自 rt.scripts(按平台选择的 scripts.pwsh.js / scripts.posix.js)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function createSnapshots(ctx, rt, config) {
|
|
11
|
+
const sessions = ctx.sessions
|
|
12
|
+
const state = rt.state
|
|
13
|
+
// 平台选择的脚本模板(rt.scripts = scripts.pwsh.js / scripts.posix.js):
|
|
14
|
+
// 两套导出同名接口但实现分属 pwsh/bash,所有调用统一走 S.*
|
|
15
|
+
const S = rt.scripts
|
|
16
|
+
// 基础排除表随调用透传给脚本模板(用户 config 可调,即时生效)
|
|
17
|
+
// 运行时读取而非创建时快照:设置卡片热更新 baseExcludes 后,
|
|
18
|
+
// 下一次快照/diff/回退立即按新排除表执行
|
|
19
|
+
const BASE = () => config.baseExcludes
|
|
20
|
+
|
|
21
|
+
// 连续失败熔断(issue #7):失败快照的重试既无谓地全量扫描工作区,
|
|
22
|
+
// 又持续写入无 tag 可达的残骸对象(实测一个下午 127GB)。连续
|
|
23
|
+
// FUSE_AFTER 次失败后按 BASE 起步、指数翻倍、CAP 封顶的退避跳过
|
|
24
|
+
// 快照——冷却期内的消息不产生快照点,换来磁盘与 CPU 止血;冷却期满
|
|
25
|
+
// 自动重试,成功一次即全部复位,用户修好环境(如补排除配置)后
|
|
26
|
+
// 无需任何手动干预。
|
|
27
|
+
const FUSE_AFTER = 3
|
|
28
|
+
const FUSE_BACKOFF_BASE_MS = 5 * 60 * 1000
|
|
29
|
+
const FUSE_BACKOFF_CAP_MS = 60 * 60 * 1000
|
|
30
|
+
const snapFailures = new Map()
|
|
31
|
+
|
|
32
|
+
// 索引落盘:任意长度文本统一走 rt.writeTextViaShell(win32 base64
|
|
33
|
+
// 分块 / POSIX stdin,实现见 store.js)——saveIndex 与 writeExclude
|
|
34
|
+
// 曾逐字重复这套平台分叉,改一处漏一处的风险随合并消失。
|
|
35
|
+
async function saveIndex(root, sessionId) {
|
|
36
|
+
const store = state.stores.get(root)
|
|
37
|
+
if (!store) return
|
|
38
|
+
// 每条带 root:设置页「快照管理」要跨工作区展示列表,而 store 目录名
|
|
39
|
+
// 是 root 的单向哈希、反解不了——index.json 是唯一能持久「哈希↔工作区
|
|
40
|
+
// 路径」对应关系的地方。loadIndex 忽略 entry.root(以参数为准),
|
|
41
|
+
// 旧版本插件读新索引也只取已知字段,双向兼容。
|
|
42
|
+
const entries = Array.from(state.snapshots.entries())
|
|
43
|
+
.filter(([, s]) => s.root === root)
|
|
44
|
+
.map(([id, s]) => ({ id, time: s.time, root: s.root, sessionId: s.sessionId }))
|
|
45
|
+
try {
|
|
46
|
+
await rt.writeTextViaShell(store.dir + (rt.isWin ? '\\' : '/') + 'index.json', JSON.stringify(entries))
|
|
47
|
+
} catch (error) {
|
|
48
|
+
rt.recordError('recall saveIndex failed: ' + String(error))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function loadIndex(root, sessionId) {
|
|
53
|
+
if (state.indexLoaded.has(root)) return
|
|
54
|
+
const store = state.stores.get(root)
|
|
55
|
+
if (!store) return
|
|
56
|
+
try {
|
|
57
|
+
const raw = S.stripBom(await rt.runShell(S.indexReadCmd(store.dir), { stdoutMaxBytes: 4194304 })).trim()
|
|
58
|
+
if (!raw) { state.indexLoaded.add(root); return }
|
|
59
|
+
const entries = JSON.parse(raw)
|
|
60
|
+
if (!Array.isArray(entries)) { state.indexLoaded.add(root); return }
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (!entry || typeof entry.id !== 'string') continue
|
|
63
|
+
state.snapshots.set(entry.id, {
|
|
64
|
+
root,
|
|
65
|
+
time: typeof entry.time === 'number' ? entry.time : Date.now(),
|
|
66
|
+
sessionId: entry.sessionId || sessionId
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
// 只在读取链路全部走通后才标记已载入:若在 try 前抢先标记,
|
|
70
|
+
// runShell 失败(shell 未就绪等)被吞后该 root 本次进程内被永久
|
|
71
|
+
// 视为「已载入」,索引永远为空、撤回按钮消失直到重启 DSH。
|
|
72
|
+
state.indexLoaded.add(root)
|
|
73
|
+
} catch (error) {
|
|
74
|
+
/* 索引缺失或损坏时按空历史处理;不标记已载入,下次自然重试 */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// exclude.txt 原文读取(设置页编辑用):stripBom 剥掉 PS 5.1 Set-Content
|
|
79
|
+
// 写入的 UTF-8 BOM,避免设置页首行出现不可见的 \uFEFF;两套模板对缺失
|
|
80
|
+
// 文件都输出空串,这里不用区分「没配过」和「配了空」。
|
|
81
|
+
async function readExclude(store) {
|
|
82
|
+
return S.stripBom(await rt.runShell(S.excludeReadCmd(store.excludeFile), { stdoutMaxBytes: 1048576 }))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// exclude.txt 原文写入(设置页保存):先 mkdir 父目录兜底(home 根目录
|
|
86
|
+
// /降级 store 目录被用户手滑删掉时,保存不该因此失败),写本体统一走
|
|
87
|
+
// rt.writeTextViaShell,与 saveIndex 共用同一套平台分叉原语。
|
|
88
|
+
async function writeExclude(store, text) {
|
|
89
|
+
const body = String(text == null ? '' : text)
|
|
90
|
+
const sep = rt.isWin ? '\\' : '/'
|
|
91
|
+
const parent = store.excludeFile.slice(0, store.excludeFile.lastIndexOf(sep))
|
|
92
|
+
await rt.runShell(S.mkdirScript(parent), { stdoutMaxBytes: 4096 })
|
|
93
|
+
await rt.writeTextViaShell(store.excludeFile, body)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 索引丢失时从仓库 tag 重建:tag 名 snap-<messageId> 本身就是快照主键
|
|
97
|
+
async function rebuildOrphans(root, sessionId) {
|
|
98
|
+
const store = state.stores.get(root)
|
|
99
|
+
const gitExe = await rt.resolveGit()
|
|
100
|
+
if (!store || !gitExe) return
|
|
101
|
+
try {
|
|
102
|
+
const listing = S.stripBom(await rt.runShell(S.listTagsScript(store, gitExe), { stdoutMaxBytes: 4194304 })).trim()
|
|
103
|
+
if (!listing) return
|
|
104
|
+
for (const name of listing.split(/\r?\n/)) {
|
|
105
|
+
const id = name.trim().replace(/^snap-/, '')
|
|
106
|
+
if (!id || state.snapshots.has(id)) continue
|
|
107
|
+
state.snapshots.set(id, { root, time: 0, sessionId })
|
|
108
|
+
}
|
|
109
|
+
await saveIndex(root, sessionId)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
rt.recordError('recall rebuildOrphans failed: ' + String(error))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function captureSnapshot(sessionId, messageId, time) {
|
|
116
|
+
const root = await rt.resolveRoot(sessionId)
|
|
117
|
+
if (!root) return
|
|
118
|
+
// 熔断检查要在任何解析/建仓动作之前:冷却期内连 resolveStore 都
|
|
119
|
+
// 不必跑,把失败重试的开销也一并止住
|
|
120
|
+
const fused = snapFailures.get(root)
|
|
121
|
+
if (fused && Date.now() < fused.skipUntil) return
|
|
122
|
+
let store = await rt.resolveStore(root)
|
|
123
|
+
store = await rt.tryUpgradeToHome(root)
|
|
124
|
+
const ok = await rt.ensureGit(root, store)
|
|
125
|
+
if (!ok) return
|
|
126
|
+
await loadIndex(root, sessionId)
|
|
127
|
+
try {
|
|
128
|
+
await rt.runShell(S.snapshotScript(root, store, state.gitExe, messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
|
|
129
|
+
snapFailures.delete(root)
|
|
130
|
+
state.snapshots.set(String(messageId), { root, time: time || Date.now(), sessionId })
|
|
131
|
+
await saveIndex(root, sessionId)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
rt.recordError('recall snapshot failed: ' + String(error))
|
|
134
|
+
await handleSnapshotFailure(root, store)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 失败善后 = 清残骸 + 推进熔断。captureSnapshot 的调用方就是串行队列
|
|
139
|
+
// (见 index.js 事件接线),这两个动作留在 catch 里顺势排队执行,
|
|
140
|
+
// 与下一次快照天然互斥,无 git 锁竞态;自身整体 best-effort,
|
|
141
|
+
// 清理失败不该让队列任务以异常收场。
|
|
142
|
+
async function handleSnapshotFailure(root, store) {
|
|
143
|
+
if (store && state.gitExe) {
|
|
144
|
+
try {
|
|
145
|
+
await rt.runShell(S.pruneScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
|
|
146
|
+
} catch (error) {
|
|
147
|
+
rt.recordError('recall prune after snapshot failure failed: ' + String(error))
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const f = snapFailures.get(root) || { count: 0, skipUntil: 0 }
|
|
151
|
+
f.count++
|
|
152
|
+
if (f.count >= FUSE_AFTER) {
|
|
153
|
+
const backoff = Math.min(FUSE_BACKOFF_BASE_MS * 2 ** (f.count - FUSE_AFTER), FUSE_BACKOFF_CAP_MS)
|
|
154
|
+
const wasFused = Date.now() < f.skipUntil
|
|
155
|
+
f.skipUntil = Date.now() + backoff
|
|
156
|
+
// 只在「未熔断→熔断」的跳变沿记录:熔断期间每条消息都会走上面的
|
|
157
|
+
// 静默跳过分支,逐条记录会把最近错误环形缓冲刷成同一条目
|
|
158
|
+
if (!wasFused) rt.recordError('recall snapshot fused after ' + f.count + ' consecutive failures, backoff ' + Math.round(backoff / 60000) + 'min for: ' + root)
|
|
159
|
+
}
|
|
160
|
+
snapFailures.set(root, f)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// POSIX 侧 diff 输出是 TSV「kind<TAB>path」逐行(bash 模板不拼 JSON,
|
|
164
|
+
// 避免 jq 依赖与转义坑);win32 侧是 ConvertTo-Json。这里按平台分叉解析。
|
|
165
|
+
function parseChanges(text) {
|
|
166
|
+
if (rt.isWin) {
|
|
167
|
+
const parsed = JSON.parse(text)
|
|
168
|
+
if (Array.isArray(parsed)) return parsed
|
|
169
|
+
if (parsed && typeof parsed === 'object') return [parsed]
|
|
170
|
+
return []
|
|
171
|
+
}
|
|
172
|
+
const out = []
|
|
173
|
+
for (const line of text.split(/\r?\n/)) {
|
|
174
|
+
if (!line) continue
|
|
175
|
+
const tab = line.indexOf('\t')
|
|
176
|
+
if (tab < 0) continue
|
|
177
|
+
out.push({ kind: line.slice(0, tab), rel: line.slice(tab + 1) })
|
|
178
|
+
}
|
|
179
|
+
return out
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 变更清单截断上限:防止超大工作区(几千个文件)把 DOM 与 JSON
|
|
183
|
+
// 双双撑爆。清单对用户的价值集中在前若干条,其余以 truncated 计数
|
|
184
|
+
// 汇总展示;total 保留完整计数让面板文案仍准确。
|
|
185
|
+
const MAX_CHANGES = 500
|
|
186
|
+
|
|
187
|
+
async function diffFor(messageId) {
|
|
188
|
+
const snap = state.snapshots.get(String(messageId))
|
|
189
|
+
if (!snap) return null
|
|
190
|
+
const store = state.stores.get(snap.root)
|
|
191
|
+
if (!store) return null
|
|
192
|
+
// 8MB 上限:按平均每条 60 字节估算可容纳十余万条,正常项目远够;
|
|
193
|
+
// 真超限时报错文案与「JSON 半截解析失败」的真实原因脱节,需显式检测
|
|
194
|
+
const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 8388608 }))
|
|
195
|
+
const trimmed = text.trim()
|
|
196
|
+
if (!trimmed) return { changes: [], total: 0, truncated: false }
|
|
197
|
+
const all = parseChanges(trimmed)
|
|
198
|
+
return { changes: all.slice(0, MAX_CHANGES), total: all.length, truncated: all.length > MAX_CHANGES }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function rollbackFor(messageId) {
|
|
202
|
+
const snap = state.snapshots.get(String(messageId))
|
|
203
|
+
if (!snap) return { ok: false, error: '该消息没有可用的项目快照' }
|
|
204
|
+
const store = state.stores.get(snap.root)
|
|
205
|
+
if (!store) return { ok: false, error: '快照存储不可用' }
|
|
206
|
+
const text = S.stripBom(await rt.runShell(S.rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 }))
|
|
207
|
+
const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/)
|
|
208
|
+
const deleted = m ? parseInt(m[1], 10) : 0
|
|
209
|
+
const restored = m ? parseInt(m[2], 10) : 0
|
|
210
|
+
return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 在事件序列里找“该消息之前最近一次 turn/end 的 seq”。
|
|
214
|
+
function scanCutSeq(events, messageId) {
|
|
215
|
+
let anchor = -1
|
|
216
|
+
for (let i = 0; i < events.length; i++) {
|
|
217
|
+
const e = events[i]
|
|
218
|
+
if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
|
|
219
|
+
anchor = i
|
|
220
|
+
break
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (anchor < 0) return null
|
|
224
|
+
for (let i = anchor - 1; i >= 0; i--) {
|
|
225
|
+
const e = events[i]
|
|
226
|
+
if (e && e.type === 'turn/end' && typeof e.seq === 'number') return e.seq
|
|
227
|
+
}
|
|
228
|
+
return null
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 解析“整段回退”的会话切点:优先读 live 会话的内存事件(零 IO、毫秒级),
|
|
232
|
+
// 冷会话回退到 sessionQuery.readSession;结果按 (会话, 消息) 缓存——
|
|
233
|
+
// 消息一旦入日志,其之前的 turn/end 永不变化,缓存终身有效。
|
|
234
|
+
async function resolveCutSeq(sessionId, messageId) {
|
|
235
|
+
if (!sessionId || !messageId) return null
|
|
236
|
+
const cacheKey = String(sessionId) + '\u0000' + String(messageId)
|
|
237
|
+
if (state.cutSeqCache.has(cacheKey)) return state.cutSeqCache.get(cacheKey)
|
|
238
|
+
let result = null
|
|
239
|
+
const live = sessions.get(sessionId)
|
|
240
|
+
if (live && Array.isArray(live.events)) {
|
|
241
|
+
result = scanCutSeq(live.events, messageId)
|
|
242
|
+
} else {
|
|
243
|
+
const query = ctx.get('sessionQuery')
|
|
244
|
+
if (query) {
|
|
245
|
+
try {
|
|
246
|
+
const log = await query.readSession(sessionId)
|
|
247
|
+
result = scanCutSeq(Array.isArray(log && log.events) ? log.events : [], messageId)
|
|
248
|
+
} catch (error) {
|
|
249
|
+
result = null
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
state.cutSeqCache.set(cacheKey, result)
|
|
254
|
+
return result
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq }
|
|
258
|
+
}
|