dsh-recall-plugin 1.6.1 → 1.7.0

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 CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
4
4
 
5
+ ## [1.7.0] - 2026-08-25
6
+
7
+ ### 新增
8
+
9
+ - 快照失败/跳过可见性([#7](https://github.com/limbo947/dsh-recall-plugin/issues/7) 加固项 1):快照失败或熔断时客户端 toast 提示(同一故障文本 10 分钟节流,避免持续故障期间刷屏),轮询到失败即终止、不再空等 20 次;熔断期间的新消息会收到「已暂停,N 分钟后自动重试」提示而非沉默。「按钮为什么消失了」的排障成本由此消掉。
10
+ - `git add --ignore-errors` fail-open 兜底([#7](https://github.com/limbo947/dsh-recall-plugin/issues/7) 加固项 3):无法索引的路径(无提交的嵌入式仓库、不可读文件等)以退出码 1 结束但索引照常落盘——快照缺个别路径可接受,好过整条快照 fatal。被跳过的路径以 SNAP_SKIP 行回传,客户端提示「快照已跳过未纳入的路径」(这些路径撤回时既不恢复也不会被删,与排除表语义一致)。
11
+ - 失败后孤儿进程清扫 + stale 锁清理([#7](https://github.com/limbo947/dsh-recall-plugin/issues/7) 加固项 4):runShell 失败路径按 `--git-dir=<本仓库>` 命令行标记定位漏网孤儿进程并终止(win: `taskkill /T /F`,POSIX: `pgrep`+`kill`),随后清理 index.lock 等残留锁——DSH subprocess 服务的树级终止有竞态窗口,且 git 被硬杀不做锁回收,残留的 index.lock 会让后续每条快照持续 fatal。
12
+
13
+ ### 修复
14
+
15
+ - `git add` fatal 时脚本假成功(空树快照):pwsh 对原生命令非零退出不抛错(ErrorActionPreference 不作用于 native),此前 add fatal 后脚本会带着未更新的旧索引继续走完 write-tree/commit/tag,产出空树 tag 且退出码 0——快照「成功」却什么都回退不了。现显式检查退出码(≥2 抛错终止),diff/rollback 的同款 add 一并修复。
16
+
17
+ ## [1.6.2] - 2026-08-25
18
+
19
+ ### 修复
20
+
21
+ - 用户消息图片显示空白且刷新无效([#8](https://github.com/limbo947/dsh-recall-plugin/issues/8)):会话回放时最早的图片消息先于会话 binding 就绪渲染,`loadImage` 以 unknown session 拒绝后被静默 `catch` 吞掉,图片永久空白。图片加载改为 400ms/1.5s/4s 三次退避自动重试(暂态失败自愈),耗尽后显示「图片加载失败,点击重试」失败态(对齐官方 MessageImage 语义);顺带修复 attachment 切换时短暂残留上一张图的问题。
22
+ - 快照连续失败无限累积磁盘残骸([#7](https://github.com/limbo947/dsh-recall-plugin/issues/7) 评论实测一个下午 127GB dangling 对象):失败重试的每次 `git add` 都会写入无 tag 可达的残骸对象。两道防线——
23
+ - **失败清理**:每次快照失败后执行 `git prune`(以 refs + 暂存 index 为根做可达性删除),只清当次残骸、不碰任何 tag 快照,已暂存对象保留以维持下次增量。
24
+ - **熔断退避**:连续 3 次失败后按 5min 起步指数翻倍、60min 封顶的退避跳过快照,冷却期满自动重试,成功一次即全部复位;进入熔断只在跳变沿记一条最近错误。
25
+
5
26
  ## [1.6.1] - 2026-08-23
6
27
 
7
28
  ### 新增
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
  ![悬停出现撤回按钮](docs/screenshots/recall-button.png)
@@ -23,7 +26,7 @@
23
26
  | --- | --- |
24
27
  | ![确认面板 · 变更文件清单](docs/screenshots/confirm-panel-1.png) | ![确认面板](docs/screenshots/confirm-panel-2.png) |
25
28
 
26
- - 撤回后自动把消息文本回填到输入框,方便修改后重发(可在设置卡片关闭)
29
+ - 撤回后自动把消息文本回填到输入框,方便修改后重发(可在设置卡片关闭)
27
30
  - 设置页 · 插件配置卡片(阈值 / 排除表 / 快照管理,保存即热生效)
28
31
 
29
32
  ![设置页](docs/screenshots/settings-exclude-2.png)
@@ -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}',
@@ -122,9 +124,9 @@ window.__ModuleLoader__.load({
122
124
  // 降级提示:每个种类每次页面加载只弹一次(Set 去重),避免切会话时
123
125
  // 反复打扰。纯 DOM 直插(与剪贴板同样的零依赖思路),7 秒后自动淡出。
124
126
  const noticeShown = new Set()
125
- function showNotice(kind, text) {
126
- if (noticeShown.has(kind) || typeof document === 'undefined') return
127
- noticeShown.add(kind)
127
+ // toast 挂载本体:两类提示(降级/快照反馈)共用的纯 DOM 实现
128
+ function mountToast(text) {
129
+ if (typeof document === 'undefined') return
128
130
  try {
129
131
  const el = document.createElement('div')
130
132
  el.className = 'dsh-recall-toast'
@@ -149,6 +151,25 @@ window.__ModuleLoader__.load({
149
151
  }
150
152
  } catch (e) { /* 提示失败不影响主流程 */ }
151
153
  }
154
+ function showNotice(kind, text) {
155
+ if (noticeShown.has(kind)) return
156
+ noticeShown.add(kind)
157
+ mountToast(text)
158
+ }
159
+ // 快照失败/跳过提示(issue #7 失败可见性):与降级提示不同,这类
160
+ // 事件会在持续故障期间随每条消息反复发生——按「文本前缀 + 时间窗」
161
+ // 节流,同一故障 10 分钟内至多打扰一次;不同错误(文本不同)各自
162
+ // 独立计数。Map 规模封顶后整体清空:提示是尽力而为的可见性,不是
163
+ // 需要精确保留的状态。
164
+ const toastLastShown = new Map()
165
+ function showThrottledToast(text) {
166
+ const key = String(text).slice(0, 80)
167
+ const now = Date.now()
168
+ if (now - (toastLastShown.get(key) || 0) < 10 * 60 * 1000) return
169
+ if (toastLastShown.size > 50) toastLastShown.clear()
170
+ toastLastShown.set(key, now)
171
+ mountToast(text)
172
+ }
152
173
 
153
174
  // 每个会话只向 Host 注册一次(预热其根目录解析缓存)。
154
175
  // 返回 init 的 promise:Host 端 init 要跑数条 PowerShell(建仓/loadIndex),
@@ -247,23 +268,50 @@ window.__ModuleLoader__.load({
247
268
  )
248
269
  }
249
270
 
271
+ // 图片加载失败的暂态判定(issue #8):会话回放时最早的图片消息先于
272
+ // 会话 binding 就绪渲染,loadImage 以 unknown session 拒绝——这类
273
+ // 失败靠短退避自动重试即可自愈;此前 `.catch(() => {})` 把失败静默
274
+ // 吞掉且 src 恒为 null,图片永久空白且刷新无法恢复。自动重试耗尽
275
+ // 后置 failed,由 ImageBox 提供「点击重试」(对齐官方 MessageImage
276
+ // 的失败态语义),attempt 仅被手动重试递增以复用整条加载链。
277
+ const IMAGE_RETRY_DELAYS = [400, 1500, 4000]
278
+
250
279
  function useImageSrc(attachment, loadImage) {
251
280
  const [src, setSrc] = React.useState(null)
281
+ const [failed, setFailed] = React.useState(false)
282
+ const [attempt, setAttempt] = React.useState(0)
252
283
  React.useEffect(() => {
253
284
  let alive = true
285
+ let timer = null
254
286
  if (!attachment || typeof loadImage !== 'function') return undefined
255
- Promise.resolve(loadImage(attachment)).then((url) => {
256
- if (alive && url) setSrc(String(url))
257
- }).catch(() => {})
258
- return () => { alive = false }
259
- }, [attachment, loadImage])
260
- return src
287
+ // 复位旧状态:attachment 变化或手动重试时清掉上一轮的 src/failed,
288
+ // 避免加载期间短暂显示上一张图或残留的失败态
289
+ setSrc(null)
290
+ setFailed(false)
291
+ const tryLoad = (retried) => {
292
+ Promise.resolve(loadImage(attachment)).then((url) => {
293
+ if (!alive) return
294
+ if (url) setSrc(String(url))
295
+ else if (retried < IMAGE_RETRY_DELAYS.length) timer = setTimeout(() => tryLoad(retried + 1), IMAGE_RETRY_DELAYS[retried])
296
+ else setFailed(true)
297
+ }).catch(() => {
298
+ if (!alive) return
299
+ if (retried < IMAGE_RETRY_DELAYS.length) timer = setTimeout(() => tryLoad(retried + 1), IMAGE_RETRY_DELAYS[retried])
300
+ else setFailed(true)
301
+ })
302
+ }
303
+ tryLoad(0)
304
+ return () => { alive = false; if (timer) clearTimeout(timer) }
305
+ }, [attachment, loadImage, attempt])
306
+ return { src, failed, retry: () => setAttempt(attempt + 1) }
261
307
  }
262
308
 
263
309
  function ImageBox(props) {
264
- const src = useImageSrc(props.attachment, props.loadImage)
265
- if (!src) return null
266
- return React.createElement('img', { className: 'dsh-recall-img', src, alt: '' })
310
+ const { src, failed, retry } = useImageSrc(props.attachment, props.loadImage)
311
+ if (src) return React.createElement('img', { className: 'dsh-recall-img', src, alt: '' })
312
+ // 加载中保持原零占位语义(不撑气泡);只在确定失败后渲染可重试入口
313
+ if (failed) return React.createElement('button', { type: 'button', className: 'dsh-recall-img-failed', onClick: retry }, '图片加载失败,点击重试')
314
+ return null
267
315
  }
268
316
 
269
317
  // kind 语义单表承载(文案/徽章类名/汇总顺序):新增 kind 时只改
@@ -389,12 +437,25 @@ window.__ModuleLoader__.load({
389
437
  function schedule() {
390
438
  if (!alive || !messageId) return
391
439
  attempts++
392
- api('snapshot-info', { messageId }).then((res) => {
440
+ api('snapshot-info', { messageId, sessionId }).then((res) => {
393
441
  if (!alive) return
394
442
  if (res && res.has) {
443
+ // fail-open 跳过的路径:快照存在但个别目录没进去(撤回不
444
+ // 恢复也不删它们)——仅对正在发生的消息提示,让用户知道
445
+ // 快照少了什么(issue #7 失败可见性)
446
+ if (recent && Array.isArray(res.skipped) && res.skipped.length) {
447
+ const names = res.skipped.slice(0, 5).join('、') + (res.skipped.length > 5 ? ' 等 ' + res.skipped.length + ' 项' : '')
448
+ showThrottledToast('快照已跳过未纳入的路径:' + names + '(撤回不会恢复或删除这些路径)')
449
+ }
395
450
  setHasSnapshot(true)
396
451
  return
397
452
  }
453
+ // 失败/熔断是终止态:快照不会迟到,提示后停止轮询——
454
+ // 「按钮为什么消失了」的排障成本由这条提示消掉(issue #7)
455
+ if (res && res.failed) {
456
+ if (recent) showThrottledToast('快照失败:' + String(res.error || '未知原因').slice(0, 140))
457
+ return
458
+ }
398
459
  if (recent && attempts < MAX_ATTEMPTS) timer = setTimeout(schedule, RETRY_MS)
399
460
  }).catch(() => {
400
461
  if (alive && recent && attempts < MAX_ATTEMPTS) timer = setTimeout(schedule, RETRY_MS)
package/lib/index.js CHANGED
@@ -524,7 +524,11 @@ export function apply(ctx, config) {
524
524
  'snapshot-info': async (args) => {
525
525
  const id = args && args.messageId ? String(args.messageId) : ''
526
526
  const snap = state.snapshots.get(id)
527
- return { has: Boolean(snap), time: snap ? snap.time : null, id }
527
+ // 失败/跳过/熔断反馈(issue #7 失败可见性):客户端轮询到 failed
528
+ // 终止轮询并 toast,不再空等 20 次;has 时附带 skipped 让用户知道
529
+ // fail-open 跳过了哪些路径
530
+ const feedback = await snaps.feedbackFor(args && args.sessionId, id)
531
+ return { has: Boolean(snap), time: snap ? snap.time : null, id, ...feedback }
528
532
  },
529
533
 
530
534
  'preview': async (args) => {
@@ -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
+ }
@@ -161,7 +161,19 @@ export function snapshotScript(root, store, gitExe, messageId, base) {
161
161
  'root=' + psq(root),
162
162
  dropGitlinksBlock(),
163
163
  excludeSyncBlock(store.excludeFile, base),
164
- '"$git" --git-dir="$g" --work-tree="$root" add -A',
164
+ // fail-open add(issue #7 加固,语义见 pwsh 版同款注释):--ignore-errors
165
+ // 下「无法索引的路径」以退出码 1 结束但索引已落盘;≥2 才是真 fatal,
166
+ // 显式退出让 runShell 抛错(set -e 对 add 非零本会终止,但 || rc=$?
167
+ // 捕获后必须自检,否则 tolerated/fatal 无法区分)。stderr 合并进变量
168
+ // 供 fatal 时带回诊断与 SNAP_SKIP 提取。
169
+ 'addrc=0',
170
+ 'add_log=$("$git" --git-dir="$g" --work-tree="$root" add -A --ignore-errors 2>&1) || addrc=$?',
171
+ 'if [ "$addrc" -ge 2 ]; then printf \'%s\\n\' "$add_log" >&2; exit "$addrc"; fi',
172
+ "printf '%s\\n' \"$add_log\" | sed -n \"s/^error: unable to index file '\\(.*\\)'$/\\1/p\" | sort -u | while IFS= read -r sk; do",
173
+ // 循环体用 if/fi 而非 && 列表:&& 列表条件为假时整条管道退出码为 1,
174
+ // set -e 会把脚本杀掉(if 语句天然豁免)
175
+ ' if [ -n "$sk" ]; then printf \'SNAP_SKIP %s\\n\' "$sk"; fi',
176
+ 'done',
165
177
  dropGitlinksBlock(),
166
178
  oversizeBlock(store.maxFileBytes),
167
179
  'tree=$("$git" --git-dir="$g" --work-tree="$root" write-tree)',
@@ -185,7 +197,12 @@ function collectListsBlock(store, gitExe, root, tag, base) {
185
197
  'root=' + psq(root),
186
198
  dropGitlinksBlock(),
187
199
  excludeSyncBlock(store.excludeFile, base),
188
- '"$git" --git-dir="$g" --work-tree="$root" add -A',
200
+ // fail-open add(语义见 snapshotScript 同款注释):diff/rollback 的当前
201
+ // 清单来自这次 add 后的索引——被跳过的路径不进清单,diff 不显示、
202
+ // rollback 删除清单也不会误删它们;≥2 显式退出防「旧索引假成功」
203
+ 'addrc=0',
204
+ '"$git" --git-dir="$g" --work-tree="$root" add -A --ignore-errors || addrc=$?',
205
+ '[ "$addrc" -le 1 ] || exit "$addrc"',
189
206
  dropGitlinksBlock(),
190
207
  oversizeBlock(store.maxFileBytes),
191
208
  'tmpc=' + psq(store.dir + '/diff-cur.$$'),
@@ -279,6 +296,40 @@ export function gcScript(store, gitExe) {
279
296
  ].join('\n')
280
297
  }
281
298
 
299
+ // 快照失败后的残骸清理(语义同 pwsh 版,动机详见其注释):prune 以
300
+ // refs + 暂存 index 为根删无引用对象,只清失败 add 的残骸、不碰 tag 快照
301
+ export function pruneScript(store, gitExe) {
302
+ return [
303
+ 'set -e',
304
+ 'git=' + psq(gitExe),
305
+ 'g=' + psq(store.git),
306
+ '"$git" --git-dir="$g" prune',
307
+ 'echo PRUNE_OK'
308
+ ].join('\n')
309
+ }
310
+
311
+ // 失败后的孤儿进程清扫 + stale 锁清理(issue #7,语义与动机见 pwsh 版
312
+ // 同款注释)。POSIX 版差异:pgrep -f 按扩展正则匹配整条命令行——路径
313
+ // 元字符([、+ 等)会让模式失配,清扫静默失效,属安全降级(等价于本兜底
314
+ // 加入前的行为);标记在运行期拼接,本脚本自身命令行里只有未展开的 $g
315
+ // 字面量,不会自杀。全程无 set -e + 逐步容错:清扫自身的失败不能抛。
316
+ export function killOrphansScript(gitDir) {
317
+ return [
318
+ '# RECALL_CLEANUP',
319
+ 'g=' + psq(gitDir),
320
+ 'marker="--git-dir=$g"',
321
+ 'for p in $(pgrep -f -- "$marker" 2>/dev/null); do',
322
+ ' [ "$p" = "$$" ] && continue',
323
+ ' kill "$p" 2>/dev/null || true',
324
+ 'done',
325
+ // 锁清单对齐 pwsh 版:index.lock 是 add/checkout 持久锁,其余是
326
+ // gc/tag/pack 链路残留;refs 下 per-ref 锁用 find 兜底
327
+ 'rm -f "$g/index.lock" "$g/config.lock" "$g/HEAD.lock" "$g/gc.pid" "$g/packed-refs.lock" "$g/shallow.lock" 2>/dev/null || true',
328
+ 'find "$g/refs" -type f -name "*.lock" -delete 2>/dev/null || true',
329
+ 'echo CLEANUP_DONE'
330
+ ].join('\n')
331
+ }
332
+
282
333
  // 删除指定快照 tag(会话已删联动清理用):tag -d 对不存在 tag 非零退出,
283
334
  // || true 吞掉——best-effort,残留 tag 由下次清理幂等收尾(同 pwsh 版)
284
335
  export function purgeTagsScript(store, gitExe, tags) {