dsh-recall-plugin 1.6.2 → 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 +12 -0
- package/lib/client.js +36 -4
- 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,18 @@
|
|
|
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
|
+
|
|
5
17
|
## [1.6.2] - 2026-08-25
|
|
6
18
|
|
|
7
19
|
### 修复
|
package/lib/client.js
CHANGED
|
@@ -124,9 +124,9 @@ window.__ModuleLoader__.load({
|
|
|
124
124
|
// 降级提示:每个种类每次页面加载只弹一次(Set 去重),避免切会话时
|
|
125
125
|
// 反复打扰。纯 DOM 直插(与剪贴板同样的零依赖思路),7 秒后自动淡出。
|
|
126
126
|
const noticeShown = new Set()
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
// toast 挂载本体:两类提示(降级/快照反馈)共用的纯 DOM 实现
|
|
128
|
+
function mountToast(text) {
|
|
129
|
+
if (typeof document === 'undefined') return
|
|
130
130
|
try {
|
|
131
131
|
const el = document.createElement('div')
|
|
132
132
|
el.className = 'dsh-recall-toast'
|
|
@@ -151,6 +151,25 @@ window.__ModuleLoader__.load({
|
|
|
151
151
|
}
|
|
152
152
|
} catch (e) { /* 提示失败不影响主流程 */ }
|
|
153
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
|
+
}
|
|
154
173
|
|
|
155
174
|
// 每个会话只向 Host 注册一次(预热其根目录解析缓存)。
|
|
156
175
|
// 返回 init 的 promise:Host 端 init 要跑数条 PowerShell(建仓/loadIndex),
|
|
@@ -418,12 +437,25 @@ window.__ModuleLoader__.load({
|
|
|
418
437
|
function schedule() {
|
|
419
438
|
if (!alive || !messageId) return
|
|
420
439
|
attempts++
|
|
421
|
-
api('snapshot-info', { messageId }).then((res) => {
|
|
440
|
+
api('snapshot-info', { messageId, sessionId }).then((res) => {
|
|
422
441
|
if (!alive) return
|
|
423
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
|
+
}
|
|
424
450
|
setHasSnapshot(true)
|
|
425
451
|
return
|
|
426
452
|
}
|
|
453
|
+
// 失败/熔断是终止态:快照不会迟到,提示后停止轮询——
|
|
454
|
+
// 「按钮为什么消失了」的排障成本由这条提示消掉(issue #7)
|
|
455
|
+
if (res && res.failed) {
|
|
456
|
+
if (recent) showThrottledToast('快照失败:' + String(res.error || '未知原因').slice(0, 140))
|
|
457
|
+
return
|
|
458
|
+
}
|
|
427
459
|
if (recent && attempts < MAX_ATTEMPTS) timer = setTimeout(schedule, RETRY_MS)
|
|
428
460
|
}).catch(() => {
|
|
429
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
|
-
|
|
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)
|