dsh-recall-plugin 1.6.2 → 1.7.1
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 +18 -0
- package/lib/client.js +54 -59
- package/lib/index.js +5 -1
- package/lib/scripts.posix.js +41 -2
- package/lib/scripts.pwsh.js +57 -3
- package/lib/snapshots.js +44 -2
- package/lib/store.js +33 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
|
4
4
|
|
|
5
|
+
## [1.7.1] - 2026-08-25
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- 历史会话中用户消息图片从未渲染([#9](https://github.com/limbo947/dsh-recall-plugin/issues/9)):插件渲染器读取的 `props.loadImage` 在官方 `conversation.chat.node` slot 契约中**从不存在**(实际入口是 `props.renderMessageImages`),自研加载链在守卫处直接 return——v1.6.2 的重试链、v1.7.0 的失败按钮全部从未执行,图片永久无声空白(v1.6.2/#8 的修复因此「修了却无效」)。用户消息图片改走官方 `renderMessageImages` 管线(自带鉴权、缓存、失败重试与灯箱预览),布局对齐官方(图片在上、气泡在下);自研 `ImageBox`/`useImageSrc` 及对应 CSS 作为死代码移除。
|
|
10
|
+
|
|
11
|
+
## [1.7.0] - 2026-08-25
|
|
12
|
+
|
|
13
|
+
### 新增
|
|
14
|
+
|
|
15
|
+
- 快照失败/跳过可见性([#7](https://github.com/limbo947/dsh-recall-plugin/issues/7) 加固项 1):快照失败或熔断时客户端 toast 提示(同一故障文本 10 分钟节流,避免持续故障期间刷屏),轮询到失败即终止、不再空等 20 次;熔断期间的新消息会收到「已暂停,N 分钟后自动重试」提示而非沉默。「按钮为什么消失了」的排障成本由此消掉。
|
|
16
|
+
- `git add --ignore-errors` fail-open 兜底([#7](https://github.com/limbo947/dsh-recall-plugin/issues/7) 加固项 3):无法索引的路径(无提交的嵌入式仓库、不可读文件等)以退出码 1 结束但索引照常落盘——快照缺个别路径可接受,好过整条快照 fatal。被跳过的路径以 SNAP_SKIP 行回传,客户端提示「快照已跳过未纳入的路径」(这些路径撤回时既不恢复也不会被删,与排除表语义一致)。
|
|
17
|
+
- 失败后孤儿进程清扫 + 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。
|
|
18
|
+
|
|
19
|
+
### 修复
|
|
20
|
+
|
|
21
|
+
- `git add` fatal 时脚本假成功(空树快照):pwsh 对原生命令非零退出不抛错(ErrorActionPreference 不作用于 native),此前 add fatal 后脚本会带着未更新的旧索引继续走完 write-tree/commit/tag,产出空树 tag 且退出码 0——快照「成功」却什么都回退不了。现显式检查退出码(≥2 抛错终止),diff/rollback 的同款 add 一并修复。
|
|
22
|
+
|
|
5
23
|
## [1.6.2] - 2026-08-25
|
|
6
24
|
|
|
7
25
|
### 修复
|
package/lib/client.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* dsh-recall-plugin — Client 半(持久插件形态,浏览器 bundle)
|
|
3
3
|
*
|
|
4
4
|
* 职责:注册 conversation.chat.node 的 user 渲染器,重绘用户消息气泡
|
|
5
|
-
* (文本/图片/JSON
|
|
5
|
+
* (文本/图片/JSON 块;图片经官方 props.renderMessageImages 管线渲染,
|
|
6
|
+
* 见 UserRecallNode 内注释),在复制按钮旁加「撤回」按钮;二次确认面板展示
|
|
6
7
|
* 文件变更清单,确认后经 /api/recall/* 调用 Host 回退文件,并用官方
|
|
7
8
|
* sessions.fork 把对话一并回退(新会话打开、原会话归档)。
|
|
8
9
|
*
|
|
@@ -38,9 +39,6 @@ window.__ModuleLoader__.load({
|
|
|
38
39
|
'.dsh-recall-row{flex-direction:column;align-items:flex-end;gap:6px;display:flex}',
|
|
39
40
|
'.dsh-recall-stack{flex-direction:column;align-items:flex-end;gap:8px;min-width:0;max-width:min(525px,82%);display:flex}',
|
|
40
41
|
'.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
|
-
'.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)}',
|
|
44
42
|
'.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)}',
|
|
45
43
|
'.dsh-recall-actions{align-items:center;gap:10px;height:28px;display:flex}',
|
|
46
44
|
'.dsh-recall-time{color:var(--dsw-alias-label-tertiary);white-space:nowrap;padding-right:12px;font-size:14px;line-height:24px}',
|
|
@@ -124,9 +122,9 @@ window.__ModuleLoader__.load({
|
|
|
124
122
|
// 降级提示:每个种类每次页面加载只弹一次(Set 去重),避免切会话时
|
|
125
123
|
// 反复打扰。纯 DOM 直插(与剪贴板同样的零依赖思路),7 秒后自动淡出。
|
|
126
124
|
const noticeShown = new Set()
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
125
|
+
// toast 挂载本体:两类提示(降级/快照反馈)共用的纯 DOM 实现
|
|
126
|
+
function mountToast(text) {
|
|
127
|
+
if (typeof document === 'undefined') return
|
|
130
128
|
try {
|
|
131
129
|
const el = document.createElement('div')
|
|
132
130
|
el.className = 'dsh-recall-toast'
|
|
@@ -151,6 +149,25 @@ window.__ModuleLoader__.load({
|
|
|
151
149
|
}
|
|
152
150
|
} catch (e) { /* 提示失败不影响主流程 */ }
|
|
153
151
|
}
|
|
152
|
+
function showNotice(kind, text) {
|
|
153
|
+
if (noticeShown.has(kind)) return
|
|
154
|
+
noticeShown.add(kind)
|
|
155
|
+
mountToast(text)
|
|
156
|
+
}
|
|
157
|
+
// 快照失败/跳过提示(issue #7 失败可见性):与降级提示不同,这类
|
|
158
|
+
// 事件会在持续故障期间随每条消息反复发生——按「文本前缀 + 时间窗」
|
|
159
|
+
// 节流,同一故障 10 分钟内至多打扰一次;不同错误(文本不同)各自
|
|
160
|
+
// 独立计数。Map 规模封顶后整体清空:提示是尽力而为的可见性,不是
|
|
161
|
+
// 需要精确保留的状态。
|
|
162
|
+
const toastLastShown = new Map()
|
|
163
|
+
function showThrottledToast(text) {
|
|
164
|
+
const key = String(text).slice(0, 80)
|
|
165
|
+
const now = Date.now()
|
|
166
|
+
if (now - (toastLastShown.get(key) || 0) < 10 * 60 * 1000) return
|
|
167
|
+
if (toastLastShown.size > 50) toastLastShown.clear()
|
|
168
|
+
toastLastShown.set(key, now)
|
|
169
|
+
mountToast(text)
|
|
170
|
+
}
|
|
154
171
|
|
|
155
172
|
// 每个会话只向 Host 注册一次(预热其根目录解析缓存)。
|
|
156
173
|
// 返回 init 的 promise:Host 端 init 要跑数条 PowerShell(建仓/loadIndex),
|
|
@@ -249,52 +266,6 @@ window.__ModuleLoader__.load({
|
|
|
249
266
|
)
|
|
250
267
|
}
|
|
251
268
|
|
|
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
|
-
|
|
260
|
-
function useImageSrc(attachment, loadImage) {
|
|
261
|
-
const [src, setSrc] = React.useState(null)
|
|
262
|
-
const [failed, setFailed] = React.useState(false)
|
|
263
|
-
const [attempt, setAttempt] = React.useState(0)
|
|
264
|
-
React.useEffect(() => {
|
|
265
|
-
let alive = true
|
|
266
|
-
let timer = null
|
|
267
|
-
if (!attachment || typeof loadImage !== 'function') return undefined
|
|
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) }
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function ImageBox(props) {
|
|
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
|
|
296
|
-
}
|
|
297
|
-
|
|
298
269
|
// kind 语义单表承载(文案/徽章类名/汇总顺序):新增 kind 时只改
|
|
299
270
|
// 这一处,避免出现「映射表改了、CSS 类名忘了」的半改状态
|
|
300
271
|
const KIND_INFO = {
|
|
@@ -385,14 +356,21 @@ window.__ModuleLoader__.load({
|
|
|
385
356
|
|
|
386
357
|
function UserRecallNode(props) {
|
|
387
358
|
const node = props && props.node
|
|
388
|
-
|
|
359
|
+
// 图片渲染入口:官方 ChatNodeSeat 传给本 slot 的 props 契约只有
|
|
360
|
+
// renderMessageImages(内部经 conversation.message.images slot 渲染
|
|
361
|
+
// 官方 MessageImages,自带鉴权、缓存、失败重试与灯箱预览)。
|
|
362
|
+
// 契约里从不存在 loadImage(issue #9):v1.6/v1.7 读该不存在的
|
|
363
|
+
// 字段导致自研加载链从未执行、用户消息图片永久无声空白。
|
|
364
|
+
const renderMessageImages = props && props.renderMessageImages
|
|
389
365
|
const sessionId = props && props.sessionId
|
|
390
366
|
const data = node && node.data ? node.data : {}
|
|
391
367
|
// node.id 是会话事件匹配时写入的真实消息 ID;node.key 是位置键(如 13:input),不能用于快照查询
|
|
392
368
|
const messageId = node ? String(node.id || node.key || '') : ''
|
|
393
369
|
const blocks = Array.isArray(data.content) ? data.content : []
|
|
394
370
|
const text = blocks.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('')
|
|
395
|
-
|
|
371
|
+
// 官方契约:images 传 image 块数组({attachment}),官方内部取
|
|
372
|
+
// image.attachment.attachmentId —— 不是裸 attachment 对象
|
|
373
|
+
const imageBlocks = blocks.filter((b) => b && b.type === 'image' && b.attachment).map((b) => ({ attachment: b.attachment }))
|
|
396
374
|
const rest = blocks.filter((b) => !b || !(b.type === 'text' && typeof b.text === 'string') && !(b.type === 'image' && b.attachment))
|
|
397
375
|
|
|
398
376
|
const [copied, setCopied] = React.useState(false)
|
|
@@ -418,12 +396,25 @@ window.__ModuleLoader__.load({
|
|
|
418
396
|
function schedule() {
|
|
419
397
|
if (!alive || !messageId) return
|
|
420
398
|
attempts++
|
|
421
|
-
api('snapshot-info', { messageId }).then((res) => {
|
|
399
|
+
api('snapshot-info', { messageId, sessionId }).then((res) => {
|
|
422
400
|
if (!alive) return
|
|
423
401
|
if (res && res.has) {
|
|
402
|
+
// fail-open 跳过的路径:快照存在但个别目录没进去(撤回不
|
|
403
|
+
// 恢复也不删它们)——仅对正在发生的消息提示,让用户知道
|
|
404
|
+
// 快照少了什么(issue #7 失败可见性)
|
|
405
|
+
if (recent && Array.isArray(res.skipped) && res.skipped.length) {
|
|
406
|
+
const names = res.skipped.slice(0, 5).join('、') + (res.skipped.length > 5 ? ' 等 ' + res.skipped.length + ' 项' : '')
|
|
407
|
+
showThrottledToast('快照已跳过未纳入的路径:' + names + '(撤回不会恢复或删除这些路径)')
|
|
408
|
+
}
|
|
424
409
|
setHasSnapshot(true)
|
|
425
410
|
return
|
|
426
411
|
}
|
|
412
|
+
// 失败/熔断是终止态:快照不会迟到,提示后停止轮询——
|
|
413
|
+
// 「按钮为什么消失了」的排障成本由这条提示消掉(issue #7)
|
|
414
|
+
if (res && res.failed) {
|
|
415
|
+
if (recent) showThrottledToast('快照失败:' + String(res.error || '未知原因').slice(0, 140))
|
|
416
|
+
return
|
|
417
|
+
}
|
|
427
418
|
if (recent && attempts < MAX_ATTEMPTS) timer = setTimeout(schedule, RETRY_MS)
|
|
428
419
|
}).catch(() => {
|
|
429
420
|
if (alive && recent && attempts < MAX_ATTEMPTS) timer = setTimeout(schedule, RETRY_MS)
|
|
@@ -565,10 +556,14 @@ window.__ModuleLoader__.load({
|
|
|
565
556
|
}
|
|
566
557
|
|
|
567
558
|
const bubbleChildren = []
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
559
|
+
// 图片在上、气泡在下:布局顺序对齐官方 UserStyleBubble(历史会话
|
|
560
|
+
// 里用户消息的观感与官方一致)。Fragment 包一层仅为挂 key。
|
|
561
|
+
if (imageBlocks.length && typeof renderMessageImages === 'function') {
|
|
562
|
+
bubbleChildren.push(React.createElement(React.Fragment, { key: 'images' },
|
|
563
|
+
renderMessageImages({ images: imageBlocks, align: 'end' })
|
|
564
|
+
))
|
|
571
565
|
}
|
|
566
|
+
if (text !== '') bubbleChildren.push(React.createElement('div', { className: 'dsh-recall-bubble', key: 'text' }, text))
|
|
572
567
|
for (let i = 0; i < rest.length; i++) {
|
|
573
568
|
bubbleChildren.push(React.createElement('pre', { className: 'dsh-recall-json', key: 'rest-' + i }, JSON.stringify(rest[i], null, 2)))
|
|
574
569
|
}
|
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
|
-
|
|
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) => {
|
package/lib/scripts.posix.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.$$'),
|
|
@@ -291,6 +308,28 @@ export function pruneScript(store, gitExe) {
|
|
|
291
308
|
].join('\n')
|
|
292
309
|
}
|
|
293
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
|
+
|
|
294
333
|
// 删除指定快照 tag(会话已删联动清理用):tag -d 对不存在 tag 非零退出,
|
|
295
334
|
// || true 吞掉——best-effort,残留 tag 由下次清理幂等收尾(同 pwsh 版)
|
|
296
335
|
export function purgeTagsScript(store, gitExe, tags) {
|
package/lib/scripts.pwsh.js
CHANGED
|
@@ -177,7 +177,24 @@ export function snapshotScript(root, store, gitExe, messageId, base) {
|
|
|
177
177
|
'$root = ' + psq(root),
|
|
178
178
|
dropGitlinksBlock(),
|
|
179
179
|
excludeSyncBlock(store.excludeFile, base),
|
|
180
|
-
|
|
180
|
+
// fail-open add(issue #7 加固):--ignore-errors 让「个别路径无法索引」
|
|
181
|
+
// (无提交的嵌入式仓库、不可读文件等)以退出码 1 结束但索引照常落盘,
|
|
182
|
+
// 快照缺个别路径可接受,好过整条快照 fatal。退出码 ≥2 才是真 fatal
|
|
183
|
+
// (磁盘满、index.lock 等),必须显式 throw:pwsh 对原生命令非零退出
|
|
184
|
+
// 不抛(EAP 不作用于 native),不检查就会带着未更新的旧索引走完
|
|
185
|
+
// write-tree/commit/tag,产出「空树假成功」快照(实测 PS 5.1/pwsh 7
|
|
186
|
+
// 均如此)。add 输出临时降到 Continue 再 2>&1 捕获:合并进管道会把
|
|
187
|
+
// native stderr 包装成 ErrorRecord,EAP=Stop 下直接抛 NativeCommandError,
|
|
188
|
+
// 而 PS 5.1 的 SilentlyContinue 会把合并流里的记录整个丢弃(实测 LOG
|
|
189
|
+
// 为空)——Continue 是两个版本下唯一都能拿到 stderr 文本的取值。捕获
|
|
190
|
+
// 后按 "unable to index file 'X'" 提取被跳过的路径,以 SNAP_SKIP 行
|
|
191
|
+
// 回传 JS 侧做用户可见提示。
|
|
192
|
+
"$ErrorActionPreference = 'Continue'",
|
|
193
|
+
"$addLog = (@(& $git --git-dir=$g --work-tree=$root add -A --ignore-errors 2>&1) | ForEach-Object { [string]$_ }) -join \"`n\"",
|
|
194
|
+
'$addRc = $LASTEXITCODE',
|
|
195
|
+
"$ErrorActionPreference = 'Stop'",
|
|
196
|
+
'if ($addRc -ge 2) { throw ("git add fatal (exit " + $addRc + "): " + $addLog) }',
|
|
197
|
+
"foreach ($m in [regex]::Matches($addLog, \"unable to index file '([^']+)'\") ) { Write-Output ('SNAP_SKIP ' + $m.Groups[1].Value) }",
|
|
181
198
|
dropGitlinksBlock(),
|
|
182
199
|
oversizeBlock(store.maxFileBytes),
|
|
183
200
|
'$tree = (& $git --git-dir=$g --work-tree=$root write-tree).Trim()',
|
|
@@ -202,7 +219,12 @@ export function diffScript(root, store, gitExe, tag, base) {
|
|
|
202
219
|
'$root = ' + psq(root),
|
|
203
220
|
dropGitlinksBlock(),
|
|
204
221
|
excludeSyncBlock(store.excludeFile, base),
|
|
205
|
-
|
|
222
|
+
// fail-open add:语义同 snapshotScript(--ignore-errors 跳过无法索引的
|
|
223
|
+
// 路径、≥2 显式 throw 防「旧索引假成功」);此处不提取 SNAP_SKIP——
|
|
224
|
+
// 被跳过的路径不进索引,diff 天然不显示、rollback 的删除清单来自当前
|
|
225
|
+
// 索引也天然不会误删它们
|
|
226
|
+
'& $git --git-dir=$g --work-tree=$root add -A --ignore-errors',
|
|
227
|
+
'if ($LASTEXITCODE -ge 2) { throw ("git add fatal (exit " + $LASTEXITCODE + ")") }',
|
|
206
228
|
dropGitlinksBlock(),
|
|
207
229
|
oversizeBlock(store.maxFileBytes),
|
|
208
230
|
'$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage',
|
|
@@ -253,7 +275,9 @@ export function rollbackScript(root, store, gitExe, tag, base) {
|
|
|
253
275
|
'$root = ' + psq(root),
|
|
254
276
|
dropGitlinksBlock(),
|
|
255
277
|
excludeSyncBlock(store.excludeFile, base),
|
|
256
|
-
|
|
278
|
+
// fail-open add:语义同 snapshotScript 同款注释(diff/rollback 复用)
|
|
279
|
+
'& $git --git-dir=$g --work-tree=$root add -A --ignore-errors',
|
|
280
|
+
'if ($LASTEXITCODE -ge 2) { throw ("git add fatal (exit " + $LASTEXITCODE + ")") }',
|
|
257
281
|
dropGitlinksBlock(),
|
|
258
282
|
oversizeBlock(store.maxFileBytes),
|
|
259
283
|
// 同 diffScript:-z 的 NUL 输出会被 PowerShell 捕获丢弃,改为逐行 + quotePath=false
|
|
@@ -330,6 +354,36 @@ export function pruneScript(store, gitExe) {
|
|
|
330
354
|
].join('\n')
|
|
331
355
|
}
|
|
332
356
|
|
|
357
|
+
// 失败后的孤儿进程清扫 + stale 锁清理(issue #7 实测:超时被杀的 shell 留下
|
|
358
|
+
// 孤儿 git 继续持有 index.lock 30+ 分钟)。DSH 的 subprocess 服务本身已做
|
|
359
|
+
// 树级终止(taskkill /T /F),这里是竞态窗口与旧版本漏网的兜底:按「命令行
|
|
360
|
+
// 含 --git-dir=<本仓库>」定位孤儿——该标记只出现在本插件派生的 git 进程
|
|
361
|
+
// 参数里,编辑器等无关进程不会命中;再删掉被硬杀的 git 来不及清理的锁文件
|
|
362
|
+
// (git 对强杀不做锁回收,残留 index.lock 会让后续每条快照持续 fatal)。
|
|
363
|
+
// 全程 SilentlyContinue + best-effort:调用点在 runShell 的失败路径上,
|
|
364
|
+
// 清扫自身再抛错只会掩盖原始错误。首行哨兵注释供 runShell 识别本脚本、
|
|
365
|
+
// 防止「清扫失败 → 再清扫」的递归。
|
|
366
|
+
export function killOrphansScript(gitDir) {
|
|
367
|
+
return [
|
|
368
|
+
'# RECALL_CLEANUP',
|
|
369
|
+
"$ErrorActionPreference = 'SilentlyContinue'",
|
|
370
|
+
'$g = ' + psq(gitDir),
|
|
371
|
+
// 标记用变量拼接而非字面量:本脚本进程的命令行(-Command 全文)只有
|
|
372
|
+
// 未展开的 '$g',Where-Object 不会匹配到自己
|
|
373
|
+
"$marker = '--git-dir=' + $g",
|
|
374
|
+
"Get-CimInstance Win32_Process -Filter 'CommandLine IS NOT NULL' | Where-Object { $_.CommandLine.Contains($marker) } | ForEach-Object {",
|
|
375
|
+
' & taskkill /T /F /PID $_.ProcessId | Out-Null',
|
|
376
|
+
'}',
|
|
377
|
+
// 锁清单:index.lock 是 add/checkout 的持久锁,其余是 gc/tag/pack 链路
|
|
378
|
+
// 可能残留的;refs 下的 per-ref 锁用递归兜底
|
|
379
|
+
"foreach ($n in @('index.lock','config.lock','HEAD.lock','gc.pid','packed-refs.lock','shallow.lock')) {",
|
|
380
|
+
' Remove-Item -LiteralPath (Join-Path $g $n) -Force',
|
|
381
|
+
'}',
|
|
382
|
+
"Get-ChildItem -LiteralPath (Join-Path $g 'refs') -Recurse -File -Filter '*.lock' | Remove-Item -Force",
|
|
383
|
+
"Write-Output 'CLEANUP_DONE'"
|
|
384
|
+
].join('\n')
|
|
385
|
+
}
|
|
386
|
+
|
|
333
387
|
// 删除指定快照 tag(会话已删联动清理用)。best-effort:个别 tag 已不存在时
|
|
334
388
|
// git 非零退出,但其余 tag 已被删除——所以显式 exit 0 吞掉退出码,
|
|
335
389
|
// 残留的由下一次清理幂等地收尾;JS 侧无论脚本结果都会同步索引。
|
package/lib/snapshots.js
CHANGED
|
@@ -125,16 +125,58 @@ export function createSnapshots(ctx, rt, config) {
|
|
|
125
125
|
if (!ok) return
|
|
126
126
|
await loadIndex(root, sessionId)
|
|
127
127
|
try {
|
|
128
|
-
await rt.runShell(S.snapshotScript(root, store, state.gitExe, messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
|
|
128
|
+
const out = await rt.runShell(S.snapshotScript(root, store, state.gitExe, messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
|
|
129
129
|
snapFailures.delete(root)
|
|
130
130
|
state.snapshots.set(String(messageId), { root, time: time || Date.now(), sessionId })
|
|
131
131
|
await saveIndex(root, sessionId)
|
|
132
|
+
setFeedback(messageId, { skipped: parseSkipped(out) })
|
|
132
133
|
} catch (error) {
|
|
133
134
|
rt.recordError('recall snapshot failed: ' + String(error))
|
|
135
|
+
setFeedback(messageId, { failed: true, error: String(error).slice(0, 300) })
|
|
134
136
|
await handleSnapshotFailure(root, store)
|
|
135
137
|
}
|
|
136
138
|
}
|
|
137
139
|
|
|
140
|
+
// 脚本侧 fail-open 跳过的路径(--ignore-errors 下无法索引的目录,如无
|
|
141
|
+
// 提交的嵌入式仓库)以「SNAP_SKIP <path>」行回传:这些路径不进快照,
|
|
142
|
+
// 撤回时既不恢复也不会被删,用户应当知道快照少了什么。
|
|
143
|
+
function parseSkipped(out) {
|
|
144
|
+
const skipped = []
|
|
145
|
+
for (const line of String(out || '').split(/\r?\n/)) {
|
|
146
|
+
if (line.indexOf('SNAP_SKIP ') === 0) skipped.push(line.slice('SNAP_SKIP '.length))
|
|
147
|
+
}
|
|
148
|
+
return skipped
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 逐消息反馈写入(issue #7 失败可见性):成功无跳过 → 清除(重试成功
|
|
152
|
+
// 自愈);失败/有跳过 → 记录。上限防泄漏:交替成功失败的长会话可以无限
|
|
153
|
+
// 积累,Map 保插入序做 FIFO 淘汰。
|
|
154
|
+
function setFeedback(messageId, rec) {
|
|
155
|
+
const id = String(messageId)
|
|
156
|
+
const keep = rec && ((rec.failed) || (Array.isArray(rec.skipped) && rec.skipped.length))
|
|
157
|
+
if (keep) state.snapFeedback.set(id, rec)
|
|
158
|
+
else state.snapFeedback.delete(id)
|
|
159
|
+
if (state.snapFeedback.size > 200) state.snapFeedback.delete(state.snapFeedback.keys().next().value)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// snapshot-info 端点的反馈查询:优先逐消息记录;无记录但该 root 熔断中
|
|
163
|
+
// 时反馈熔断状态(冷却期内的消息快照被静默跳过,客户端需要知道「不是
|
|
164
|
+
// 还没好,是暂停了」)。客户端只对近 5 分钟的消息弹提示,历史消息查询
|
|
165
|
+
// 不受影响。
|
|
166
|
+
async function feedbackFor(sessionId, messageId) {
|
|
167
|
+
const rec = state.snapFeedback.get(String(messageId || ''))
|
|
168
|
+
if (rec) return rec
|
|
169
|
+
if (!sessionId) return {}
|
|
170
|
+
const root = await rt.resolveRoot(sessionId)
|
|
171
|
+
if (root) {
|
|
172
|
+
const f = snapFailures.get(root)
|
|
173
|
+
if (f && Date.now() < f.skipUntil) {
|
|
174
|
+
return { failed: true, error: '快照连续失败已暂停(熔断),约 ' + Math.ceil((f.skipUntil - Date.now()) / 60000) + ' 分钟后自动重试,详情见设置 · 插件配置 · 最近错误' }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return {}
|
|
178
|
+
}
|
|
179
|
+
|
|
138
180
|
// 失败善后 = 清残骸 + 推进熔断。captureSnapshot 的调用方就是串行队列
|
|
139
181
|
// (见 index.js 事件接线),这两个动作留在 catch 里顺势排队执行,
|
|
140
182
|
// 与下一次快照天然互斥,无 git 锁竞态;自身整体 best-effort,
|
|
@@ -254,5 +296,5 @@ export function createSnapshots(ctx, rt, config) {
|
|
|
254
296
|
return result
|
|
255
297
|
}
|
|
256
298
|
|
|
257
|
-
return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq }
|
|
299
|
+
return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq, feedbackFor }
|
|
258
300
|
}
|
package/lib/store.js
CHANGED
|
@@ -42,7 +42,12 @@ export function createRuntime(ctx, config) {
|
|
|
42
42
|
gitExe: null,
|
|
43
43
|
posixHomeBase: null,
|
|
44
44
|
homeContainer: null,
|
|
45
|
-
errors: []
|
|
45
|
+
errors: [],
|
|
46
|
+
// 逐消息的快照反馈(issue #7 失败可见性):失败 {failed,error} 或
|
|
47
|
+
// fail-open 跳过 {skipped:[...]},由 snapshot-info 端点下发给客户端
|
|
48
|
+
// 弹 toast。放共享 state 而非 snapshots.js 闭包:端点在 index.js,
|
|
49
|
+
// 与索引/根缓存同层取用。
|
|
50
|
+
snapFeedback: new Map()
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
// 最近错误环形缓冲:Host 侧所有失败原本只进 console.error(宿主进程
|
|
@@ -95,12 +100,39 @@ export function createRuntime(ctx, config) {
|
|
|
95
100
|
const res = await shell.run(spec)
|
|
96
101
|
const out = (res && res.stdout && res.stdout.text) || ''
|
|
97
102
|
if (res && res.exitCode !== 0) {
|
|
103
|
+
// 失败兜底(issue #7):超时/失败的 git 命令可能留下孤儿进程与
|
|
104
|
+
// stale 锁——subprocess 服务的树级终止有竞态窗口,且 git 被硬杀时
|
|
105
|
+
// 不回收 index.lock,残留锁会让后续每条快照持续 fatal。best-effort
|
|
106
|
+
// 清扫后再抛原始错误,清扫自身的失败不得掩盖它。
|
|
107
|
+
await cleanupAfterGitFailure(command)
|
|
98
108
|
const err = ((res && res.stderr && res.stderr.text) || '').trim() || ('exit ' + String(res.exitCode))
|
|
99
109
|
throw new Error(err.slice(0, 1500))
|
|
100
110
|
}
|
|
101
111
|
return out
|
|
102
112
|
}
|
|
103
113
|
|
|
114
|
+
// 从脚本文本提取影子仓库 git-dir:两套模板的 git 命令脚本都以
|
|
115
|
+
// `$g = '<store.git>'` / `g='<store.git>'` 开头(凡带 store 的脚本全遵守
|
|
116
|
+
// 此约定),取首个带引号字面量赋值即得。resolveGitScript 等对 $g 的
|
|
117
|
+
// 非字面量赋值天然不匹配;含单引号的罕见路径会让 psq 的 '' 转义截断
|
|
118
|
+
// 提取结果——清扫脚本对错误路径只是 no-op(杀不到进程、删不到锁),
|
|
119
|
+
// 安全降级为本兜底加入前的行为。
|
|
120
|
+
function extractGitDir(command) {
|
|
121
|
+
const m = String(command).match(/(?:^|\n)[ \t]*(?:\$g|g)[ \t]*=[ \t]*'([^']+)/)
|
|
122
|
+
return m ? m[1] : null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function cleanupAfterGitFailure(command) {
|
|
126
|
+
// 哨兵识别清扫脚本自身:它也定义 $g 且可能失败(如 taskkill 缺失),
|
|
127
|
+
// 不拦住会「清扫失败 → 再清扫」无限递归
|
|
128
|
+
if (!command || String(command).indexOf('RECALL_CLEANUP') >= 0) return
|
|
129
|
+
const gitDir = extractGitDir(command)
|
|
130
|
+
if (!gitDir) return
|
|
131
|
+
try {
|
|
132
|
+
await runShell(scripts.killOrphansScript(gitDir), { timeoutMs: 60000, stdoutMaxBytes: 4096 })
|
|
133
|
+
} catch (error) { /* best-effort:清扫失败不影响原始错误的抛出 */ }
|
|
134
|
+
}
|
|
135
|
+
|
|
104
136
|
async function resolveRoot(sessionId) {
|
|
105
137
|
const key = sessionId ? String(sessionId) : 'fallback'
|
|
106
138
|
const cached = state.roots.get(key)
|