dsh-recall-plugin 2.5.2 → 2.6.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
@@ -4,6 +4,36 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.6.0] - 2026-10-30
8
+
9
+ ### 新增
10
+
11
+ - **任务队列按工作区隔离**:快照/preview/回退/删除/gc 原先共用一条全局串行链,一个工作区的慢快照会把**所有会话**的撤回按钮一起堵住。现在同一工作区仍然串行(都在写同一个 git index),不同工作区并行;跨工作区的批量删除与全局 gc 用「等这些工作区的链尾全部落地」保证互斥。win32 下路径大小写归一,避免同一仓库拿到两把锁。
12
+ - **单次快照时限可配置(`snapshotBudgetMs`,默认 3 分钟)**:整条快照脚本(扫描 + `add` + 写索引/打 tag)的兜底超时从硬编码 600 秒改为配置项,设置卡片按秒展示;`0` 表示不限制。
13
+
14
+ ### 修复
15
+
16
+ - **Linux/macOS 上快照管理、批量删除/全部删除与「立即 gc」全部不可用**([issue #30](https://github.com/limbo947/dsh-recall-plugin/issues/30),POSIX dump 脚本的两个缺陷):① 建仓后还没拍过快照的**空仓**没有 `index.json`/`lineage.json`,裸 `cat` 在 `set -e` 下让整条 dump 退出 1,磁盘上的仓一个都枚举不到;② 这两个文件落盘时没有尾换行,`cat` 后紧跟的段尾标记被粘到内容行尾,解析器按整行匹配永远收不到段尾——**即使文件齐全,每个仓也被解析成「无索引」**(报告者的临时处置只修了 ①,树依然是空的)。现在两处改为「缺席放行 + 内容后补一个换行」,逐仓隔离语义不变,存量数据无需迁移。
17
+ - **设置页 exclude 编辑器可能回显垃圾字节**(同一类行边界缺陷):`exclude.txt` 字节数为 3 的倍数时 base64 没有 `=` padding,粘在尾部的段尾标记会被一起解码;回显后再保存即污染文件。
18
+ - **dump 脚本的测试从「字符串断言」升级为「真跑一遍」**:新增 [tests/unit/stores-dump-script.test.js](tests/unit/stores-dump-script.test.js),在 bash 里执行生成的脚本并校验解析结果(CI 的 ubuntu 必然执行,win32 认 Git Bash)。此前只断言脚本文本与解析器行为,这两个只在执行期暴露的缺陷因此全绿漏过。
19
+
20
+ ### 文档
21
+
22
+ - 新增 [plan-stores-dump-posix.md](docs/plans/completed/plan-stores-dump-posix.md):issue #30 的双缺陷分析、实测矩阵与防回归设计;含「不采纳 warmup 落 `index.json = []`」「不采纳脚本保持 fail-fast」的依据,以及两条遗留(dump 失败对用户不可见、空仓无法回收)。
23
+ - 新增 [plan-queue-and-budget.md](docs/plans/completed/plan-queue-and-budget.md):含 web 新会话 cwd 的官方归属核查(`startSession` 取 `workspaceId ?? 当前 ?? 最近`、全链不传 cwd、无配置项/钩子可改写)——该项为官方行为,插件不改代码,只在 README 使用注意与 AGENTS 已知坑里写明。
24
+
25
+ ## [2.5.3] - 2026-10-30
26
+
27
+ ### 修复
28
+
29
+ - **大工作区护栏静默失效(issue #29)**:快照前的预算扫描以 `git ls-files` 为准,此前它非零退出会被当作「没超限」——`$HOME` 级工作区(523 GB / 233 万条目)上实测每条消息先写约 10 GB 对象、600 秒后被杀,prune 回收后下一条消息再重复一遍。现在统计失败一律 **fail-closed**:脚本在 `add` 之前输出 `SNAP_SCAN_FAIL` / `SNAP_SCAN_ERR` 并跳过本次快照,原因(git 的 stderr 首行)进「最近错误」,该工作区暂停快照 10 分钟(改配置或 TTL 到期自动重试),不再重付一次全量扫描。
30
+ - **超过 `maxFileBytes` 的文件不再先写对象再移出索引(issue #29)**:此前 `add -A` 会先为超大文件写入 blob,之后才由剔除逻辑移出 index——「小文件少 + 大文件多」的工作区能在预算通过后仍写满磁盘。改为先扫出大文件清单,`add` 带 `--pathspec-from-file` 的 exclude 清单执行(大文件既不进索引也不写对象),add 后仍按清单兜底剔除一次;不支持该选项的旧 git 自动退回裸 `add`(行为同旧版)。
31
+ - **分钟级失败不再免费重试两次(issue #29)**:失败耗时 ≥60 秒视为「昂贵失败」,直接进入退避(5 分钟起、指数递增),不再按「连续 3 次失败」让用户先付三轮分钟级 + GB 级写入。
32
+
33
+ ### 变更
34
+
35
+ - **快照 / 预览 / 回退三处 `add` 前统一切 cwd 到工作区根**:`--pathspec-from-file` 的隐含全集会被 cwd 前缀裁切(`:(top)` 救不回来,实测),脚本原先的 cwd 是宿主工作目录,可能正在工作区子目录里。
36
+
7
37
  ## [2.5.2] - 2026-10-28
8
38
 
9
39
  ### 修复
package/README.en.md CHANGED
@@ -155,9 +155,10 @@ All options can be edited visually in the "**Settings → Plugin Config → Reca
155
155
  | --- | --- | --- |
156
156
  | `gcSnaps` | 50 | Run `git gc` after this many snapshots accumulate (env `DSH_RECALL_GC_SNAPS` force-overrides) |
157
157
  | `gcHours` | 24 | Run gc when this many hours have passed since the last one (whichever trigger fires first; env `DSH_RECALL_GC_HOURS`) |
158
- | `maxFileBytes` | 8388608 (8MB) | Files larger than this are neither snapshotted nor touched by recalls |
158
+ | `maxFileBytes` | 8388608 (8MB) | Files larger than this are neither snapshotted nor touched by recalls; they are filtered out *before* `git add` from a path list, so no objects are written (older git falls back to add-then-remove) |
159
159
  | `maxWorkspaceBytes` | 268435456 (256MB) | Total snapshot-eligible file size per workspace, measured exactly as `git add` sees it (`.gitignore`, `.git/info/exclude` and the global excludesFile all apply). Exceeding it **disables snapshots for that workspace** (no truncation — a truncated snapshot would make rollback delete the skipped files) and shows a notice on recent messages; raise the limit or add exclusions to recover automatically. 0 = unlimited |
160
160
  | `scanBudgetMs` | 10000 (10s) | Time budget for measuring the snapshot-eligible total. Exceeding it skips the snapshot with a notice (guards against pathological directories stalling startup). 0 = unlimited |
161
+ | `snapshotBudgetMs` | 180000 (3 min) | Total time for one snapshot from scan to write; exceeding it aborts the snapshot and records it under "Recent errors". 0 = unlimited |
161
162
  | `maxSnapshotsPerWorkspace` | 500 | Maximum snapshots kept per workspace; oldest pruned beyond the cap. 0 = unlimited |
162
163
  | `retentionDays` | 0 | Keep snapshots for this many days; older ones are deleted. 0 = disabled (works independently of the cap) |
163
164
  | `baseExcludes` | `.git`, `node_modules/`, `.dsh-recall-snapshots/`, `dsh-recall-snapshots/`, `target/`, `dist/`, `build/`, `out/`, `coverage/`, `.next/`, `.nuxt/`, `.output/`, `.cache/`, `.gradle/`, `*.exe`, `*.dll`, `*.pdb`, `*.so`, `*.dylib`, `*.msi`, `*.zip`, `*.7z`, `*.rar`, `*.tar`, `*.tar.gz`, `*.iso` | Base exclusion list (gitignore syntax, lower priority than exclude.txt); excluded large directories are skipped as whole subtrees during snapshot scans. Directory-form entries (e.g. `target/`) mean one more thing: **when a path segment of the workspace root itself matches, snapshots are disabled for that workspace** — patterns are relative to the root, so they can never exclude the root itself (i.e. a session opened inside a build-output directory), and such directories have no rollback value anyway; remove the entry to re-enable |
@@ -198,7 +199,7 @@ The plugin manages disk usage automatically — no manual housekeeping needed:
198
199
 
199
200
  The most common questions and their troubleshooting paths; for the full set of design boundaries see "Known Limitations" above, and environment-class failures (git missing / disk full / permission denied / …) are explained in the settings card's "Recent errors".
200
201
 
201
- **No "↶ Recall" button next to a message?** Check in order of likelihood: (1) the DSH process wasn't restarted after installing — restart it and hard-refresh (Ctrl+Shift+R); (2) git CLI isn't on PATH — a notice shows at the top of the page; (3) the message predates the plugin — snapshots are only created when a message is sent; (4) snapshots are disabled for that workspace — it sits under a directory-form exclusion (e.g. opened inside `dist/`) or exceeded the `maxWorkspaceBytes` / `scanBudgetMs` guardrails, in which case recent messages carry a recovery notice (raise the limit or extend the exclusion list and it recovers automatically).
202
+ **No "↶ Recall" button next to a message?** Check in order of likelihood: (1) the DSH process wasn't restarted after installing — restart it and hard-refresh (Ctrl+Shift+R); (2) git CLI isn't on PATH — a notice shows at the top of the page; (3) the message predates the plugin — snapshots are only created when a message is sent; (4) snapshots are disabled for that workspace — it sits under a directory-form exclusion (e.g. opened inside `dist/`), exceeded the `maxWorkspaceBytes` / `scanBudgetMs` guardrails, or the pre-snapshot size scan itself failed (`git ls-files` error; the reason is in "Recent errors" and it retries after 10 minutes) — recent messages carry a recovery notice (raise the limit or extend the exclusion list and it recovers automatically).
202
203
 
203
204
  **Some files weren't restored by a recall?** They weren't in the snapshot: larger than `maxFileBytes` (8MB by default), matched by the exclusion list, or inside a nested git repository / a win32 reparse point (see "Known Limitations"). The confirmation panel's file list before a recall is the exact rollback scope — trust it.
204
205
 
package/README.md CHANGED
@@ -152,9 +152,10 @@ peerDependencies 按 minor 版本线开窗,与 `dsh.compatibility.dshReleases`
152
152
  | --- | --- | --- |
153
153
  | `gcSnaps` | 50 | 每积累多少条快照触发一次 `git gc`(env `DSH_RECALL_GC_SNAPS` 可强制覆盖) |
154
154
  | `gcHours` | 24 | 距上次 gc 超过多少小时触发(与条数先到先触发;env `DSH_RECALL_GC_HOURS`) |
155
- | `maxFileBytes` | 8388608(8MB) | 超过该大小的文件不进快照、不被回退触碰 |
155
+ | `maxFileBytes` | 8388608(8MB) | 超过该大小的文件不进快照、不被回退触碰;在 `git add` 之前就按清单剔除,不写对象(旧 git 退回「先加后剔」) |
156
156
  | `maxWorkspaceBytes` | 268435456(256MB) | 单工作区可纳入快照的文件总量上限,口径与 `git add` 一致(`.gitignore`、`.git/info/exclude`、全局 excludesFile 都算数)。超过时**停用该工作区快照**(不截断——截断快照回退时会误删未纳入的文件),并在最近消息上提示;调大该值或补充排除表后自动恢复;0 = 不限制 |
157
157
  | `scanBudgetMs` | 10000(10 秒) | 快照前统计纳入总量的时间预算。超过时停用本次快照并提示(防病态大目录拖死启动);0 = 不限制 |
158
+ | `snapshotBudgetMs` | 180000(3 分钟) | 单次快照从扫描到写入的总时限,超时即中止本次快照并记入「最近错误」;0 = 不限制 |
158
159
  | `maxSnapshotsPerWorkspace` | 500 | 每个工作区保留的最大快照数,超限自动删除最旧的;0 = 不限制 |
159
160
  | `retentionDays` | 0 | 按天数保留快照,超期自动删除;0 = 不启用(与条数上限各自独立生效) |
160
161
  | `baseExcludes` | `.git`、`node_modules/`、`.dsh-recall-snapshots/`、`dsh-recall-snapshots/`、`target/`、`dist/`、`build/`、`out/`、`coverage/`、`.next/`、`.nuxt/`、`.output/`、`.cache/`、`.gradle/`、`*.exe`、`*.dll`、`*.pdb`、`*.so`、`*.dylib`、`*.msi`、`*.zip`、`*.7z`、`*.rar`、`*.tar`、`*.tar.gz`、`*.iso` | 基础排除表(gitignore 语法,优先级低于 exclude.txt);被排除的大目录整棵子树不进快照扫描。其中「目录形态」项(如 `target/`)另有一层含义:**工作区根自身的路径段命中时,该工作区不启用快照**——在构建产物目录里开会话时排除表本来就管不到它自己(模式是相对该 root 的),这类目录也没有回退价值;删掉对应项即恢复 |
@@ -195,7 +196,7 @@ peerDependencies 按 minor 版本线开窗,与 `dsh.compatibility.dshReleases`
195
196
 
196
197
  使用中最常遇到的几类问题与排查路径;更完整的设计边界见上文「已知限制」,环境类失败(git 缺失 / 磁盘满 / 无权限等)的原因在设置卡片「最近错误」里可查。
197
198
 
198
- **消息旁没有「↶ 撤回」按钮?** 按概率排查:① 装完没重启 DSH 进程——重启后硬刷新页面(Ctrl+Shift+R);② git CLI 不在 PATH——页面顶部会有安装提示;③ 该消息发送于插件启用之前——快照只在消息发送时创建,历史消息无快照;④ 该工作区被停用快照——工作区开在 `dist/`、`target/` 这类命中基础排除表目录形态的位置,或超出 `maxWorkspaceBytes` / `scanBudgetMs` 护栏,最近消息上会给出恢复提示(调大上限或补充排除表后自动恢复)。
199
+ **消息旁没有「↶ 撤回」按钮?** 按概率排查:① 装完没重启 DSH 进程——重启后硬刷新页面(Ctrl+Shift+R);② git CLI 不在 PATH——页面顶部会有安装提示;③ 该消息发送于插件启用之前——快照只在消息发送时创建,历史消息无快照;④ 该工作区被停用快照——工作区开在 `dist/`、`target/` 这类命中基础排除表目录形态的位置,或超出 `maxWorkspaceBytes` / `scanBudgetMs` 护栏,或快照前的范围统计本身失败(`git ls-files` 报错,原因见「最近错误」,10 分钟后自动重试);最近消息上会给出恢复提示(调大上限或补充排除表后自动恢复)。
199
200
 
200
201
  **撤回后个别文件没有恢复?** 该文件不在快照里:超过 `maxFileBytes`(默认 8MB)、命中排除表,或位于嵌套 git 仓库 / win32 目录重解析点内(见「已知限制」)。撤回前确认面板的清单就是实际回退范围,以它为准。
201
202
 
package/lib/client.js CHANGED
@@ -592,6 +592,9 @@
592
592
  "config.scanBudgetMs.label": "\u626B\u63CF\u65F6\u95F4\u9884\u7B97",
593
593
  "config.scanBudgetMs.hint": "\u5FEB\u7167\u524D\u7EDF\u8BA1\u7EB3\u5165\u603B\u91CF\u8D85\u8FC7\u8BE5\u65F6\u957F\u5373\u505C\u7528\u672C\u6B21\u5FEB\u7167\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236",
594
594
  "config.scanBudgetMs.suffix": "\u79D2",
595
+ "config.snapshotBudgetMs.label": "\u5355\u6B21\u5FEB\u7167\u65F6\u9650",
596
+ "config.snapshotBudgetMs.hint": "\u5355\u6B21\u5FEB\u7167\u4ECE\u626B\u63CF\u5230\u5199\u5165\u7684\u603B\u65F6\u9650\uFF0C\u8D85\u65F6\u5373\u4E2D\u6B62\u672C\u6B21\u5FEB\u7167\u5E76\u8BB0\u5165\u6700\u8FD1\u9519\u8BEF\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236",
597
+ "config.snapshotBudgetMs.suffix": "\u79D2",
595
598
  "config.maxSnapshotsPerWorkspace.label": "\u5FEB\u7167\u603B\u91CF\u4E0A\u9650",
596
599
  "config.maxSnapshotsPerWorkspace.hint": "\u6BCF\u5DE5\u4F5C\u533A\u4FDD\u7559\u7684\u6700\u5927\u5FEB\u7167\u6570\uFF0C\u8D85\u9650\u5220\u9664\u6700\u65E7\u7684\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236",
597
600
  "config.maxSnapshotsPerWorkspace.suffix": "\u6761",
@@ -624,6 +627,7 @@
624
627
  "config.err.maxFileBytes": "\u6587\u4EF6\u5927\u5C0F\u4E0A\u9650\u81F3\u5C11 0.01 MB",
625
628
  "config.err.maxWorkspaceBytes": "\u5DE5\u4F5C\u533A\u603B\u91CF\u4E0A\u9650\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
626
629
  "config.err.scanBudgetMs": "\u626B\u63CF\u65F6\u95F4\u9884\u7B97\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
630
+ "config.err.snapshotBudgetMs": "\u5355\u6B21\u5FEB\u7167\u65F6\u9650\u5FC5\u987B\u662F >= 0 \u7684\u6570\u5B57\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
627
631
  "config.err.maxSnapshots": "\u5FEB\u7167\u603B\u91CF\u4E0A\u9650\u5FC5\u987B\u662F >= 0 \u7684\u6574\u6570\uFF080 \u8868\u793A\u4E0D\u9650\u5236\uFF09",
628
632
  "config.err.retentionDays": "\u4FDD\u7559\u5929\u6570\u5FC5\u987B\u662F >= 0 \u7684\u6574\u6570\uFF080 \u8868\u793A\u4E0D\u542F\u7528\uFF09",
629
633
  "config.err.locale": "\u754C\u9762\u8BED\u8A00\u5FC5\u987B\u662F auto / zh / en \u4E4B\u4E00",
@@ -846,6 +850,9 @@
846
850
  "config.scanBudgetMs.label": "Scan time budget",
847
851
  "config.scanBudgetMs.hint": "Skip this snapshot when measuring the snapshot-eligible total exceeds this duration; 0 means unlimited",
848
852
  "config.scanBudgetMs.suffix": "s",
853
+ "config.snapshotBudgetMs.label": "Snapshot time limit",
854
+ "config.snapshotBudgetMs.hint": "Total time for one snapshot from scan to write; exceeding it aborts the snapshot and records it under recent errors; 0 means unlimited",
855
+ "config.snapshotBudgetMs.suffix": "s",
849
856
  "config.maxSnapshotsPerWorkspace.label": "Snapshot count limit",
850
857
  "config.maxSnapshotsPerWorkspace.hint": "Maximum snapshots kept per workspace; the oldest are deleted beyond it; 0 means unlimited",
851
858
  "config.maxSnapshotsPerWorkspace.suffix": "snapshots",
@@ -878,6 +885,7 @@
878
885
  "config.err.maxFileBytes": "The file size limit must be at least 0.01 MB",
879
886
  "config.err.maxWorkspaceBytes": "The workspace total limit must be a number >= 0 (0 means unlimited)",
880
887
  "config.err.scanBudgetMs": "The scan time budget must be a number >= 0 (0 means unlimited)",
888
+ "config.err.snapshotBudgetMs": "The snapshot time limit must be a number >= 0 (0 means unlimited)",
881
889
  "config.err.maxSnapshots": "The snapshot count limit must be an integer >= 0 (0 means unlimited)",
882
890
  "config.err.retentionDays": "Retention days must be an integer >= 0 (0 disables it)",
883
891
  "config.err.locale": "Language must be one of auto / zh / en",
@@ -2762,6 +2770,8 @@
2762
2770
  maxWorkspaceBytes: bytesToMb2(v.maxWorkspaceBytes),
2763
2771
  // 扫描预算持久化为毫秒、展示为秒(整数);0 表示不限制,原样显示 0
2764
2772
  scanBudgetMs: String(v.scanBudgetMs == null ? "" : Math.round(Number(v.scanBudgetMs) / 1e3)),
2773
+ // issue #29:单次快照总时限,同样按秒展示
2774
+ snapshotBudgetMs: String(v.snapshotBudgetMs == null ? "" : Math.round(Number(v.snapshotBudgetMs) / 1e3)),
2765
2775
  maxSnapshotsPerWorkspace: String(v.maxSnapshotsPerWorkspace == null ? "" : v.maxSnapshotsPerWorkspace),
2766
2776
  baseExcludes: Array.isArray(v.baseExcludes) ? v.baseExcludes.join("\n") : "",
2767
2777
  refillDraft: v.refillDraft !== false,
@@ -2793,7 +2803,7 @@
2793
2803
  function save() {
2794
2804
  if (state.busy || !draft || !baseline) return;
2795
2805
  const patch = {};
2796
- for (const key of ["gcSnaps", "gcHours", "maxFileBytes", "maxWorkspaceBytes", "scanBudgetMs", "maxSnapshotsPerWorkspace", "baseExcludes", "refillDraft", "snapshotEnabled", "archiveOriginal", "editSubmitsImmediately", "retentionDays", "locale"]) {
2806
+ for (const key of ["gcSnaps", "gcHours", "maxFileBytes", "maxWorkspaceBytes", "scanBudgetMs", "snapshotBudgetMs", "maxSnapshotsPerWorkspace", "baseExcludes", "refillDraft", "snapshotEnabled", "archiveOriginal", "editSubmitsImmediately", "retentionDays", "locale"]) {
2797
2807
  const value = draft[key];
2798
2808
  if (value !== void 0 && value !== baseline[key]) patch[key] = value;
2799
2809
  }
@@ -2842,6 +2852,14 @@
2842
2852
  }
2843
2853
  clean.scanBudgetMs = Math.round(s * 1e3);
2844
2854
  }
2855
+ if (patch.snapshotBudgetMs !== void 0) {
2856
+ const s = Number(patch.snapshotBudgetMs);
2857
+ if (!Number.isFinite(s) || s < 0) {
2858
+ setState({ busy: false, message: t("config.err.snapshotBudgetMs"), error: true });
2859
+ return;
2860
+ }
2861
+ clean.snapshotBudgetMs = Math.round(s * 1e3);
2862
+ }
2845
2863
  if (patch.maxSnapshotsPerWorkspace !== void 0) {
2846
2864
  const n = parseInt(String(patch.maxSnapshotsPerWorkspace), 10);
2847
2865
  if (!Number.isFinite(n) || n < 0) {
@@ -3021,6 +3039,8 @@
3021
3039
  // issue #24 护栏:单文件上限之外的两道闸(总量 + 扫描耗时),0 = 不限制
3022
3040
  numRow("maxWorkspaceBytes", t("config.maxWorkspaceBytes.label"), t("config.maxWorkspaceBytes.hint"), { suffixKey: "config.maxWorkspaceBytes.suffix", min: 0, step: 16 }),
3023
3041
  numRow("scanBudgetMs", t("config.scanBudgetMs.label"), t("config.scanBudgetMs.hint"), { suffixKey: "config.scanBudgetMs.suffix", min: 0, step: 1 }),
3042
+ // issue #29:扫描阶段之外还有 add/写索引——单次快照的总时限兜底
3043
+ numRow("snapshotBudgetMs", t("config.snapshotBudgetMs.label"), t("config.snapshotBudgetMs.hint"), { suffixKey: "config.snapshotBudgetMs.suffix", min: 0, step: 30 }),
3024
3044
  numRow("maxSnapshotsPerWorkspace", t("config.maxSnapshotsPerWorkspace.label"), t("config.maxSnapshotsPerWorkspace.hint"), { suffixKey: "config.maxSnapshotsPerWorkspace.suffix", min: 0, step: 1 }),
3025
3045
  numRow("retentionDays", t("config.retentionDays.label"), t("config.retentionDays.hint"), { suffixKey: "config.retentionDays.suffix", min: 0, step: 1 }),
3026
3046
  React.createElement("div", { className: "dsh-recall-cfg-group" }, t("config.group.ui")),
package/lib/config.js CHANGED
@@ -40,6 +40,9 @@ const Config = Schema.object({
40
40
  // 截断快照回退时,未纳入的文件会命中回退脚本的「当前有、目标无」删除侧。
41
41
  maxWorkspaceBytes: withVolatile(Schema.number().default(268435456).description("\u5355\u5DE5\u4F5C\u533A\u53EF\u7EB3\u5165\u5FEB\u7167\u7684\u6587\u4EF6\u603B\u91CF\u4E0A\u9650\uFF0C\u53E3\u5F84\u4E0E git add \u4E00\u81F4\uFF08\u542B .gitignore\uFF09\uFF0C\u8D85\u8FC7\u5373\u505C\u7528\u8BE5\u5DE5\u4F5C\u533A\u5FEB\u7167\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236")),
42
42
  scanBudgetMs: withVolatile(Schema.number().default(1e4).description("\u5FEB\u7167\u524D\u7EDF\u8BA1\u7EB3\u5165\u603B\u91CF\u7684\u65F6\u95F4\u9884\u7B97\uFF0C\u8D85\u65F6\u5373\u505C\u7528\u672C\u6B21\u5FEB\u7167\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236")),
43
+ // issue #29:scanBudgetMs 只约束「统计阶段」,真正的耗时与写盘在 add。
44
+ // 这条是整条快照脚本的兜底时限——慢盘或病态工作区上它才是把队列拖住的一段。
45
+ snapshotBudgetMs: withVolatile(Schema.number().default(18e4).description("\u5355\u6B21\u5FEB\u7167\u7684\u603B\u65F6\u9650\uFF08\u6BEB\u79D2\uFF0C\u542B\u626B\u63CF\u4E0E\u5199\u5165\uFF09\uFF0C\u8D85\u65F6\u5373\u4E2D\u6B62\u672C\u6B21\u5FEB\u7167\u5E76\u8BB0\u5F55\uFF1B0 \u8868\u793A\u4E0D\u9650\u5236")),
43
46
  maxSnapshotsPerWorkspace: withVolatile(Schema.number().default(500).description("\u6BCF\u4E2A\u5DE5\u4F5C\u533A\u4FDD\u7559\u7684\u6700\u5927\u5FEB\u7167\u6570\uFF0C\u8D85\u9650\u5220\u9664\u6700\u65E7\u7684")),
44
47
  baseExcludes: withVolatile(Schema.array(Schema.string()).default(BASE_EXCLUDES).description("\u57FA\u7840\u6392\u9664\u8868\uFF08gitignore \u8BED\u6CD5\uFF0C\u4F18\u5148\u7EA7\u4F4E\u4E8E exclude.txt\uFF09")),
45
48
  refillDraft: withVolatile(Schema.boolean().default(true).description("\u64A4\u56DE\u540E\u628A\u88AB\u64A4\u56DE\u7684\u6D88\u606F\uFF08\u6587\u672C\u4E0E\u9644\u4EF6\uFF09\u56DE\u586B\u5230\u8F93\u5165\u6846")),
@@ -129,6 +132,7 @@ const DEFAULTS = {
129
132
  maxFileBytes: 8388608,
130
133
  maxWorkspaceBytes: 268435456,
131
134
  scanBudgetMs: 1e4,
135
+ snapshotBudgetMs: 18e4,
132
136
  maxSnapshotsPerWorkspace: 500,
133
137
  baseExcludes: BASE_EXCLUDES,
134
138
  refillDraft: true,
@@ -150,6 +154,7 @@ function createConfig(raw) {
150
154
  const maxFileBytes = pickNumber(cfg.maxFileBytes, 8388608, 1024);
151
155
  const maxWorkspaceBytes = pickNumber(cfg.maxWorkspaceBytes, 268435456, 0);
152
156
  const scanBudgetMs = pickNumber(cfg.scanBudgetMs, 1e4, 0);
157
+ const snapshotBudgetMs = pickNumber(cfg.snapshotBudgetMs, 18e4, 0);
153
158
  const rawMax = typeof cfg.maxSnapshotsPerWorkspace === "number" ? cfg.maxSnapshotsPerWorkspace : parseInt(String(cfg.maxSnapshotsPerWorkspace == null ? "" : cfg.maxSnapshotsPerWorkspace), 10);
154
159
  const maxSnapshotsPerWorkspace = Number.isFinite(rawMax) ? Math.max(0, rawMax) : 500;
155
160
  const baseExcludes = Array.isArray(cfg.baseExcludes) && cfg.baseExcludes.length ? cfg.baseExcludes.filter((p) => typeof p === "string" && p.trim()) : BASE_EXCLUDES;
@@ -160,7 +165,7 @@ function createConfig(raw) {
160
165
  const rawDays = typeof cfg.retentionDays === "number" ? cfg.retentionDays : parseInt(String(cfg.retentionDays == null ? "" : cfg.retentionDays), 10);
161
166
  const retentionDays = Number.isFinite(rawDays) ? Math.max(0, rawDays) : 0;
162
167
  const locale = cfg.locale === "zh" || cfg.locale === "en" ? cfg.locale : "auto";
163
- return { gcSnaps, gcHours, maxFileBytes, maxWorkspaceBytes, scanBudgetMs, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, archiveOriginal, editSubmitsImmediately, retentionDays, locale };
168
+ return { gcSnaps, gcHours, maxFileBytes, maxWorkspaceBytes, scanBudgetMs, snapshotBudgetMs, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, archiveOriginal, editSubmitsImmediately, retentionDays, locale };
164
169
  }
165
170
  export {
166
171
  Config,
package/lib/index.js CHANGED
@@ -9,6 +9,7 @@ import { createRoutesCore } from "./routes-core.js";
9
9
  import { createRoutesManage } from "./routes-manage.js";
10
10
  import { createIntentJournal } from "./intent-journal.js";
11
11
  import { createEnsureRootReady } from "./root-ready.js";
12
+ import { createQueues } from "./queues.js";
12
13
  import * as dshSettings from "@deepseek-ai/dsh-settings";
13
14
  import * as E from "./errors.js";
14
15
  const name = "dsh-recall-plugin";
@@ -55,12 +56,7 @@ function apply(ctx, config) {
55
56
  if (text === E.RECALL_BODY_TOO_LARGE) return { ok: false, code: E.RECALL_BODY_TOO_LARGE, message: "\u8BF7\u6C42\u4F53\u8D85\u8FC7 1MB \u4E0A\u9650" };
56
57
  return { ok: false, code: E.RECALL_ERROR, message: text };
57
58
  }
58
- function enqueue(task) {
59
- const run = state.queue.then(task);
60
- state.queue = run.catch(() => {
61
- });
62
- return run;
63
- }
59
+ const queues = createQueues(process.platform === "win32");
64
60
  async function runLimited(tasks, concurrency) {
65
61
  const limit = concurrency > 0 ? concurrency : 4;
66
62
  let index = 0;
@@ -246,7 +242,8 @@ function apply(ctx, config) {
246
242
  state,
247
243
  cfg,
248
244
  supported,
249
- enqueue,
245
+ enqueueFor: queues.enqueueFor,
246
+ enqueueMany: queues.enqueueMany,
250
247
  agentBusy,
251
248
  runLimited,
252
249
  ensureRootReady,
@@ -314,9 +311,14 @@ function apply(ctx, config) {
314
311
  if (session && session.header && session.header.origin === "subagent") return;
315
312
  const messageId = data.id;
316
313
  const time = event.time;
317
- state.queue = state.queue.then(() => cfg.snapshotEnabled ? snaps.captureSnapshot(session.id, messageId, time) : null).then(() => maint.maybeMaintain(session.id)).then(() => {
314
+ void (async () => {
315
+ const root = await rt.resolveRoot(session.id);
316
+ await queues.enqueueFor(root, async () => {
317
+ if (cfg.snapshotEnabled) await snaps.captureSnapshot(session.id, messageId, time);
318
+ });
319
+ await queues.enqueueFor(root, () => maint.maybeMaintain(session.id));
318
320
  listCache.stale = true;
319
- }).catch((error) => rt.recordError("recall snapshot error: " + String(error)));
321
+ })().catch((error) => rt.recordError("recall snapshot error: " + String(error)));
320
322
  });
321
323
  (async () => {
322
324
  let shellReady = false;
package/lib/queues.js ADDED
@@ -0,0 +1,30 @@
1
+ function createQueues(isWin) {
2
+ const tails = /* @__PURE__ */ new Map();
3
+ const keyOf = (root) => {
4
+ const k = String(root == null ? "" : root);
5
+ return k !== "" && isWin ? k.toLowerCase() : k;
6
+ };
7
+ function tailFor(key) {
8
+ return tails.get(key) || Promise.resolve();
9
+ }
10
+ function enqueueFor(root, task) {
11
+ const key = keyOf(root);
12
+ const run = tailFor(key).then(task);
13
+ tails.set(key, run.catch(() => {
14
+ }));
15
+ return run;
16
+ }
17
+ function enqueueMany(roots, task) {
18
+ const keys = Array.from(new Set(roots.map(keyOf)));
19
+ if (keys.length === 0) keys.push("");
20
+ const run = Promise.all(keys.map(tailFor)).then(task);
21
+ const tail = run.catch(() => {
22
+ });
23
+ for (const key of keys) tails.set(key, tail);
24
+ return run;
25
+ }
26
+ return { enqueueFor, enqueueMany };
27
+ }
28
+ export {
29
+ createQueues
30
+ };
@@ -4,7 +4,7 @@ import { buildArtifactRootSegment, buildRootNotice } from "./exclude-patterns.js
4
4
  const RECALL_EVENT_COMPLETE_HOST = "dsh-recall/complete";
5
5
  const RECALL_EVENT_FAILED_HOST = "dsh-recall/failed";
6
6
  function createRoutesCore(deps) {
7
- const { rt, snaps, state, cfg, supported, enqueue, agentBusy, rescueRollback, intentJournal, ensureRootReady, emitEvent, E } = deps;
7
+ const { rt, snaps, state, cfg, supported, enqueueFor, agentBusy, rescueRollback, intentJournal, ensureRootReady, emitEvent, E } = deps;
8
8
  return {
9
9
  "init": async (args) => {
10
10
  if (!supported) {
@@ -48,7 +48,7 @@ function createRoutesCore(deps) {
48
48
  const sessionId = args && args.sessionId ? String(args.sessionId) : null;
49
49
  const snap = state.snapshots.get(id);
50
50
  if (agentBusy(sessionId, snap ? snap.root : null)) return { ok: false, code: E.RECALL_AGENT_BUSY, message: "Agent \u6B63\u5728\u8FD0\u884C\u4E2D\uFF0C\u8BF7\u5148\u505C\u6B62\u540E\u518D\u64A4\u56DE" };
51
- const result = await enqueue(() => snaps.diffFor(id));
51
+ const result = await enqueueFor(snap ? snap.root : null, () => snaps.diffFor(id));
52
52
  if (result === null) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167" };
53
53
  const snap2 = state.snapshots.get(id);
54
54
  const cutSeq = await snaps.resolveCutSeq(sessionId, id);
@@ -66,7 +66,8 @@ function createRoutesCore(deps) {
66
66
  const staleQueueItemIds2 = await snaps.resolveStaleQueueItemIds(sessionId, cutSeq2);
67
67
  return { ok: true, count: 0, cutSeq: cutSeq2, staleQueueItemIds: staleQueueItemIds2 };
68
68
  }
69
- const result = await enqueue(async () => {
69
+ const initialSnap = state.snapshots.get(id);
70
+ const result = await enqueueFor(initialSnap ? initialSnap.root : null, async () => {
70
71
  const snap = state.snapshots.get(id);
71
72
  if (!snap) return { ok: false, code: E.RECALL_NO_SNAPSHOT, message: "\u8BE5\u6D88\u606F\u6CA1\u6709\u53EF\u7528\u7684\u9879\u76EE\u5FEB\u7167" };
72
73
  const store = state.stores.get(snap.root);
@@ -10,7 +10,8 @@ function createRoutesManage(deps) {
10
10
  state,
11
11
  cfg,
12
12
  supported,
13
- enqueue,
13
+ enqueueFor,
14
+ enqueueMany,
14
15
  runLimited,
15
16
  listExcludeFiles,
16
17
  dumpStores,
@@ -114,7 +115,7 @@ function createRoutesManage(deps) {
114
115
  else byRoot.set(rec.root, [rec.id]);
115
116
  }
116
117
  let deleted = 0;
117
- await enqueue(async () => {
118
+ await enqueueMany(Array.from(byRoot.keys()), async () => {
118
119
  for (const [root, rootIds] of byRoot) {
119
120
  let store = state.stores.get(root) || null;
120
121
  if (!store) {
@@ -152,7 +153,7 @@ function createRoutesManage(deps) {
152
153
  return deleted;
153
154
  }
154
155
  async function deleteAllSnapshots() {
155
- return enqueue(async () => {
156
+ return enqueueMany(Array.from(state.stores.keys()), async () => {
156
157
  const stores = /* @__PURE__ */ new Map();
157
158
  for (const [root, store] of state.stores.entries()) {
158
159
  if (store && store.dir) stores.set(store.dir, { store, root });
@@ -282,6 +283,8 @@ function createRoutesManage(deps) {
282
283
  maxFileBytes: cfg.maxFileBytes,
283
284
  maxWorkspaceBytes: cfg.maxWorkspaceBytes,
284
285
  scanBudgetMs: cfg.scanBudgetMs,
286
+ // issue #29:单次快照总时限(设置卡片的「单次快照时限」)
287
+ snapshotBudgetMs: cfg.snapshotBudgetMs,
285
288
  maxSnapshotsPerWorkspace: cfg.maxSnapshotsPerWorkspace,
286
289
  baseExcludes: cfg.baseExcludes.slice(),
287
290
  refillDraft: cfg.refillDraft,
@@ -526,7 +529,7 @@ function createRoutesManage(deps) {
526
529
  }
527
530
  const finalStore = store;
528
531
  const finalRoot = snapRoot;
529
- await enqueue(async () => {
532
+ await enqueueFor(finalRoot, async () => {
530
533
  if (state.gitExe) {
531
534
  await rt.runShell(rt.scripts.purgeTagsScript(finalStore, state.gitExe, ["snap-" + id]), { timeoutMs: 12e4, stdoutMaxBytes: 4096 });
532
535
  }
@@ -556,7 +559,7 @@ function createRoutesManage(deps) {
556
559
  return { ok: true, deleted: result.deleted, stores: result.stores };
557
560
  }
558
561
  if (op === "gc") {
559
- const done = sessionId ? await enqueue(() => maint.runGc(sessionId, true)) : await enqueue(() => maint.runGcAll());
562
+ const done = sessionId ? await enqueueFor(await rt.resolveRoot(sessionId), () => maint.runGc(sessionId, true)) : await enqueueMany(Array.from(state.stores.keys()), () => maint.runGcAll());
560
563
  usageCache.payload = null;
561
564
  return { ok: true, gc: Boolean(done) };
562
565
  }
@@ -36,14 +36,34 @@ function dirPruneArgsBlock() {
36
36
  "if [ ${#oversize_args[@]} -gt 0 ]; then oversize_prune=('(' -type d '(' \"${oversize_args[@]}\" ')' ')' -prune -o); fi"
37
37
  ].join("\n");
38
38
  }
39
- function oversizeBlock(maxBytes) {
39
+ function oversizeRemoveBlock() {
40
+ return 'xargs -0 "$git" --literal-pathspecs --git-dir="$g" --work-tree="$root" update-index --force-remove -- < "$oversize_r" 2>/dev/null || true';
41
+ }
42
+ function oversizeListBlock(maxBytes) {
40
43
  return [
41
44
  dirPruneArgsBlock(),
45
+ 'oversize_p="$g/oversize-pathspec.$$"',
46
+ 'oversize_r="$g/oversize-rel.$$"',
47
+ ': > "$oversize_p"',
48
+ ': > "$oversize_r"',
42
49
  'find "$root" "${oversize_prune[@]}" -type f -size +' + String(maxBytes || MAX_FILE_BYTES) + "c -print0 2>/dev/null | while IFS= read -r -d '' f; do",
43
- ` printf '%s\\0' "\${f#"$root"/}"`,
44
- // --work-tree 不可省:bare 影子仓库下裸 update-index fatal(被 || true 吞掉
45
- // 后大文件会静默留在 index 里),maxFileBytes 的承诺会形同虚设
46
- 'done | xargs -0 "$git" --literal-pathspecs --git-dir="$g" --work-tree="$root" update-index --force-remove -- 2>/dev/null || true'
50
+ ' oversize_rel="${f#"$root"/}"',
51
+ ` printf '%s\\0' ":(top,exclude,literal)$oversize_rel" >> "$oversize_p"`,
52
+ ` printf '%s\\0' "$oversize_rel" >> "$oversize_r"`,
53
+ "done",
54
+ oversizeRemoveBlock()
55
+ ].join("\n");
56
+ }
57
+ function oversizeAddPrepBlock() {
58
+ return [
59
+ "pa_args=()",
60
+ 'if [ -s "$oversize_p" ]; then',
61
+ ` if [ -f "$g/pathspec-from-file.ok" ] || "$git" add -h 2>&1 | grep -q -- '--pathspec-from-file'; then`,
62
+ ` printf '1\\n' > "$g/pathspec-from-file.ok" 2>/dev/null || true`,
63
+ ' pa_args=(--pathspec-from-file="$oversize_p" --pathspec-file-nul)',
64
+ " fi",
65
+ "fi",
66
+ 'cd "$root"'
47
67
  ].join("\n");
48
68
  }
49
69
  function limitScanBlock(maxBytes, fileMaxBytes, budgetMs) {
@@ -53,13 +73,22 @@ function limitScanBlock(maxBytes, fileMaxBytes, budgetMs) {
53
73
  return [
54
74
  "limit_start=$(date +%s)",
55
75
  'limit_tmp="$g/limit-scan.$$"',
56
- "limit_scan_ok=0",
57
- // set -e:条件位置豁免,ls-files 失败不终止脚本——fail-open 只放行不判定
58
- 'if "$git" -c core.quotePath=false --git-dir="$g" --work-tree="$root" ls-files -z -co --exclude-standard > "$limit_tmp" 2>/dev/null; then',
59
- " limit_scan_ok=1",
76
+ 'limit_err="$g/limit-scan-err.$$"',
77
+ // set -e:条件位置豁免,失败由 else 分支显式处理(不许穿透到 add)
78
+ 'if "$git" -c core.quotePath=false --git-dir="$g" --work-tree="$root" ls-files -z -co --exclude-standard > "$limit_tmp" 2> "$limit_err"; then',
79
+ " :",
80
+ "else",
81
+ " limit_rc=$?",
82
+ // 压平成单行才能随哨兵回传;截 300 字符防一条巨长 stderr 撑爆错误环
83
+ ' limit_msg=$(head -c 300 "$limit_err" 2>/dev/null | tr "\\n\\r" " ")',
84
+ ' rm -f "$limit_tmp" "$limit_err"',
85
+ ` printf 'SNAP_SCAN_FAIL %s\\n' "$limit_rc"`,
86
+ ` printf 'SNAP_SCAN_ERR %s\\n' "$limit_msg"`,
87
+ " exit 0",
60
88
  "fi",
89
+ 'rm -f "$limit_err"',
61
90
  'limit_sizes=""',
62
- 'if [ "$limit_scan_ok" = 1 ] && [ -s "$limit_tmp" ]; then',
91
+ 'if [ -s "$limit_tmp" ]; then',
63
92
  // ls-files 输出相对 root 的路径,而脚本 cwd 是宿主工作目录:必须 cd 进去再 stat
64
93
  ` if find "$root" -maxdepth 0 -printf '' >/dev/null 2>&1; then`,
65
94
  ` limit_sizes=$(cd "$root" && xargs -0 stat -c '%s %F' -- < "$limit_tmp" 2>/dev/null || true)`,
@@ -70,7 +99,7 @@ function limitScanBlock(maxBytes, fileMaxBytes, budgetMs) {
70
99
  'rm -f "$limit_tmp"',
71
100
  // 目录条目(嵌套仓库在 ls-files 里输出 `dir/`)不计入:add 侧同样不纳入它
72
101
  // 的内容(gitlink 被剔除 / 无提交时 unable to index);越过单文件上限的也
73
- // 不计入——oversizeBlock 随后会把它移出 index
102
+ // 不计入——oversizeListBlock 随后会把它移出 index
74
103
  `limit_total=$(printf '%s\\n' "$limit_sizes" | awk -v lim=` + limit + " -v flim=" + fileLimit + ` '{ if (tolower($0) ~ /directory/) next; if ($1 <= flim) { s += $1; if (lim > 0 && s > lim) { print "OVER " s; exit } } } END { if (lim <= 0 || s <= lim) print s }')`,
75
104
  "limit_elapsed=$(( $(date +%s) - limit_start ))",
76
105
  'case "$limit_total" in',
@@ -205,14 +234,17 @@ function snapshotScript(root, store, gitExe, messageId, base) {
205
234
  excludeSyncBlock(store.excludeFile, base),
206
235
  // issue #24:总量/预算护栏先于 add——超限时连 add 都不跑
207
236
  limitScanBlock(store.maxWorkspaceBytes, store.maxFileBytes, store.scanBudgetMs),
237
+ // issue #29:大文件清单先于 add——add 带 exclude 后不再为它们写对象
238
+ oversizeListBlock(store.maxFileBytes),
208
239
  attrsMigrateBlock(),
240
+ oversizeAddPrepBlock(),
209
241
  // fail-open add(issue #7 加固,语义见 pwsh 版同款注释):--ignore-errors
210
242
  // 下「无法索引的路径」以退出码 1 结束但索引已落盘;≥2 才是真 fatal,
211
243
  // 显式退出让 runShell 抛错(set -e 对 add 非零本会终止,但 || rc=$?
212
244
  // 捕获后必须自检,否则 tolerated/fatal 无法区分)。stderr 合并进变量
213
245
  // 供 fatal 时带回诊断与 SNAP_SKIP 提取。
214
246
  "addrc=0",
215
- 'add_log=$("$git" --git-dir="$g" --work-tree="$root" add -A --ignore-errors 2>&1) || addrc=$?',
247
+ 'add_log=$("$git" --git-dir="$g" --work-tree="$root" add -A --ignore-errors "${pa_args[@]}" 2>&1) || addrc=$?',
216
248
  `if [ "$addrc" -ge 2 ]; then printf '%s\\n' "$add_log" >&2; exit "$addrc"; fi`,
217
249
  `printf '%s\\n' "$add_log" | sed -n "s/^error: unable to index file '\\(.*\\)'$/\\1/p" | sort -u | while IFS= read -r sk; do`,
218
250
  // 循环体用 if/fi 而非 && 列表:&& 列表条件为假时整条管道退出码为 1,
@@ -220,7 +252,9 @@ function snapshotScript(root, store, gitExe, messageId, base) {
220
252
  ` if [ -n "$sk" ]; then printf 'SNAP_SKIP %s\\n' "$sk"; fi`,
221
253
  "done",
222
254
  dropGitlinksBlock(),
223
- oversizeBlock(store.maxFileBytes),
255
+ // 兜底:旧 git 走裸 add 时大文件仍会被重新索引,这里按清单再清一次
256
+ oversizeRemoveBlock(),
257
+ 'rm -f "$oversize_p" "$oversize_r"',
224
258
  'tree=$("$git" --git-dir="$g" --work-tree="$root" write-tree)',
225
259
  'commit=$("$git" --git-dir="$g" -c user.name=dsh-recall -c user.email=recall@dsh.local commit-tree "$tree" -m ' + psq("snapshot " + messageId) + ")",
226
260
  '"$git" --git-dir="$g" tag -f ' + psq("snap-" + messageId) + ' "$commit" >/dev/null',
@@ -237,14 +271,18 @@ function collectListsBlock(store, gitExe, root, tag, base) {
237
271
  "root=" + psq(root),
238
272
  dropGitlinksBlock(),
239
273
  excludeSyncBlock(store.excludeFile, base),
274
+ // issue #29:与 snapshotScript 同形——大文件不进 add(否则 preview 一次
275
+ // 也要为它们写对象),清单随 add 后的兜底剔除一起收口
276
+ oversizeListBlock(store.maxFileBytes),
277
+ oversizeAddPrepBlock(),
240
278
  // fail-open add(语义见 snapshotScript 同款注释):diff/rollback 的当前
241
279
  // 清单来自这次 add 后的索引——被跳过的路径不进清单,diff 不显示、
242
280
  // rollback 删除清单也不会误删它们;≥2 显式退出防「旧索引假成功」
243
281
  "addrc=0",
244
- '"$git" --git-dir="$g" --work-tree="$root" add -A --ignore-errors || addrc=$?',
282
+ '"$git" --git-dir="$g" --work-tree="$root" add -A --ignore-errors "${pa_args[@]}" || addrc=$?',
245
283
  '[ "$addrc" -le 1 ] || exit "$addrc"',
246
284
  dropGitlinksBlock(),
247
- oversizeBlock(store.maxFileBytes),
285
+ oversizeRemoveBlock(),
248
286
  "tmpc=" + psq(store.dir + "/diff-cur.$$"),
249
287
  "tmpt=" + psq(store.dir + "/diff-tgt.$$"),
250
288
  `"$git" -c core.quotePath=false --git-dir="$g" --work-tree="$root" ls-files --stage | grep -v '^160000 ' > "$tmpc" || true`,
@@ -255,7 +293,7 @@ function diffScript(root, store, gitExe, tag, base) {
255
293
  return [
256
294
  "set -e -o pipefail",
257
295
  collectListsBlock(store, gitExe, root, tag, base),
258
- `trap 'rm -f "$tmpc" "$tmpt"' EXIT`,
296
+ `trap 'rm -f "$tmpc" "$tmpt" "$oversize_p" "$oversize_r"' EXIT`,
259
297
  "awk -F'\\t' -v OFS='\\t' '",
260
298
  " FNR==1 { fidx++ }",
261
299
  ' fidx==1 { split($1, a, " "); cur[$2]=a[2]; next }',
@@ -278,7 +316,7 @@ function rollbackScript(root, store, gitExe, tag, base) {
278
316
  "set -e -o pipefail",
279
317
  collectListsBlock(store, gitExe, root, tag, base),
280
318
  "tmpr=" + psq(store.dir + "/diff-restore.$$"),
281
- `trap 'rm -f "$tmpc" "$tmpt" "$tmpr"' EXIT`,
319
+ `trap 'rm -f "$tmpc" "$tmpt" "$tmpr" "$oversize_p" "$oversize_r"' EXIT`,
282
320
  // 恢复集 = 目标有、当前无或 sha 不同(awk 结构与 diffScript 同款:
283
321
  // cur 侧 "mode sha stage<TAB>path" 取 a[2],target 侧 "mode type sha<TAB>path" 取 a[3])
284
322
  "awk -F'\\t' '",
@@ -314,7 +352,7 @@ function rollbackScript(root, store, gitExe, tag, base) {
314
352
  " fi",
315
353
  "fi",
316
354
  "tmpd=" + psq(store.dir + "/diff-del.$$"),
317
- `trap 'rm -f "$tmpc" "$tmpt" "$tmpr" "$tmpd"' EXIT`,
355
+ `trap 'rm -f "$tmpc" "$tmpt" "$tmpr" "$tmpd" "$oversize_p" "$oversize_r"' EXIT`,
318
356
  "awk -F'\\t' '",
319
357
  " FNR==1 { fidx++ }",
320
358
  " fidx==1 { cur[$2]=1; next }",
@@ -457,6 +495,10 @@ function excludeDumpScript(files) {
457
495
  lines.push(
458
496
  "printf 'EXCLBEGIN %s\\n' " + q,
459
497
  "if [ -f " + q + " ]; then base64 " + q + " 2>/dev/null | tr -d '\\n'; fi",
498
+ // issue #30 同族:tr -d '\n' 之后内容没有行尾,直接 echo 会把 EXCLEND 粘在
499
+ // base64 尾上;payload 字节数为 3 的倍数(无 '=' padding)时这截尾巴会被一起
500
+ // 解码成垃圾字节,回显到设置页再写回就污染 exclude.txt。
501
+ 'printf "\\n"',
460
502
  "echo 'EXCLEND'"
461
503
  );
462
504
  }
@@ -499,10 +541,16 @@ function storesDumpScript(container, extraDirs) {
499
541
  ' echo "ROOT "',
500
542
  " fi",
501
543
  " echo INDEXBEGIN",
502
- ' cat "$d/index.json" 2>/dev/null',
544
+ // issue #30:这两个 json 是可选文件(刚建仓没拍过快照就没有),缺席必须放行——
545
+ // set -e 下裸 cat 会把「无文件」升级成整条 dump 失败。另外落盘内容无尾换行
546
+ // (writeTextViaShell = cat > file),cat 后紧接 echo 会把段尾标记粘到内容行尾,
547
+ // JS 侧按整行匹配 INDEXEND 就永远收不到段尾,所以内容后显式补一个换行。
548
+ ' cat "$d/index.json" 2>/dev/null || true',
549
+ ' printf "\\n"',
503
550
  " echo INDEXEND",
504
551
  " echo LINEAGEBEGIN",
505
- ' cat "$d/lineage.json" 2>/dev/null',
552
+ ' cat "$d/lineage.json" 2>/dev/null || true',
553
+ ' printf "\\n"',
506
554
  " echo LINEAGEEND",
507
555
  "done",
508
556
  "exit 0"
@@ -56,9 +56,20 @@ function reparseDirsBlock() {
56
56
  "$reparseRel.Sort()"
57
57
  ].join("\n");
58
58
  }
59
- function oversizeBlock(maxBytes) {
59
+ function oversizeRemoveBlock() {
60
+ return [
61
+ "for ($i = 0; $i -lt $oversizeRel.Count; $i += 100) {",
62
+ " $batch = $oversizeRel.GetRange($i, [Math]::Min(100, $oversizeRel.Count - $i))",
63
+ // --work-tree 不可省:bare 影子仓库下裸 update-index 会 fatal,pwsh 不抛
64
+ // → 大文件静默留在 index 里,maxFileBytes 的承诺形同虚设
65
+ " & $git --literal-pathspecs --git-dir=$g --work-tree=$root update-index --force-remove -- $batch",
66
+ "}"
67
+ ].join("\n");
68
+ }
69
+ function oversizeListBlock(maxBytes) {
60
70
  return [
61
71
  dirSkipSetBlock("oversizeSkip"),
72
+ '$oversizeP = Join-Path $g ("oversize-pathspec." + $PID)',
62
73
  "$oversizeStack = [System.Collections.Generic.Stack[string]]::new()",
63
74
  "$oversizeStack.Push($root)",
64
75
  "$oversizeRel = [System.Collections.Generic.List[string]]::new()",
@@ -78,14 +89,44 @@ function oversizeBlock(maxBytes) {
78
89
  " }",
79
90
  " } catch {}",
80
91
  "}",
81
- "for ($i = 0; $i -lt $oversizeRel.Count; $i += 100) {",
82
- " $batch = $oversizeRel.GetRange($i, [Math]::Min(100, $oversizeRel.Count - $i))",
83
- // --work-tree 不可省:bare 影子仓库下裸 update-index 会 fatal,pwsh 不抛
84
- // → 大文件静默留在 index 里,maxFileBytes 的承诺形同虚设
85
- " & $git --literal-pathspecs --git-dir=$g --work-tree=$root update-index --force-remove -- $batch",
86
- "}"
92
+ // 清单必须存在(--pathspec-from-file 指错会让 add fatal):无大文件时写空文件,
93
+ // 后续按长度 0 判定「不传该参数」
94
+ "if ($oversizeRel.Count -gt 0) {",
95
+ " $oversizeSb = [System.Text.StringBuilder]::new()",
96
+ ' foreach ($r in $oversizeRel) { [void]$oversizeSb.Append(":(top,exclude,literal)").Append($r).Append([char]0) }',
97
+ " [System.IO.File]::WriteAllText($oversizeP, $oversizeSb.ToString(), (New-Object System.Text.UTF8Encoding($false)))",
98
+ "} else {",
99
+ ' Set-Content -LiteralPath $oversizeP -Value "" -NoNewline -Encoding ascii -ErrorAction SilentlyContinue',
100
+ "}",
101
+ oversizeRemoveBlock()
102
+ ].join("\n");
103
+ }
104
+ function oversizeAddPrepBlock() {
105
+ return [
106
+ "$paArgs = @()",
107
+ "if ((Test-Path -LiteralPath $oversizeP) -and ((Get-Item -LiteralPath $oversizeP).Length -gt 0)) {",
108
+ " $paOk = Join-Path $g 'pathspec-from-file.ok'",
109
+ " $paDetect = Test-Path -LiteralPath $paOk",
110
+ " if (-not $paDetect) {",
111
+ " $paHit = @()",
112
+ " $paEap = $ErrorActionPreference",
113
+ " $ErrorActionPreference = 'Continue'",
114
+ " try {",
115
+ " $paHit = @(& $git add -h 2>&1 | Select-String -SimpleMatch '--pathspec-from-file')",
116
+ " } finally { $ErrorActionPreference = $paEap }",
117
+ " if ($paHit.Count -gt 0) {",
118
+ " Set-Content -LiteralPath $paOk -Value 1 -Encoding ascii -ErrorAction SilentlyContinue",
119
+ " $paDetect = $true",
120
+ " }",
121
+ " }",
122
+ ' if ($paDetect) { $paArgs = @(("--pathspec-from-file=" + $oversizeP), "--pathspec-file-nul") }',
123
+ "}",
124
+ "Set-Location -LiteralPath $root"
87
125
  ].join("\n");
88
126
  }
127
+ function oversizeCleanupBlock() {
128
+ return "Remove-Item -LiteralPath $oversizeP -ErrorAction SilentlyContinue";
129
+ }
89
130
  function limitScanBlock(maxBytes, fileMaxBytes, budgetMs) {
90
131
  const limit = maxBytes > 0 ? String(maxBytes) : "0";
91
132
  const fileLimit = String(fileMaxBytes || MAX_FILE_BYTES);
@@ -96,27 +137,43 @@ function limitScanBlock(maxBytes, fileMaxBytes, budgetMs) {
96
137
  "$limitHit = ''",
97
138
  "$limitRc = 0",
98
139
  "$limitFiles = @()",
140
+ // 临时文件名带 $PID:脚本每次都是新进程,避免同一 store 上的残留互相覆盖
141
+ '$limitErrFile = Join-Path $g ("limit-scan-err." + $PID)',
99
142
  // PS 5.1:native stderr 在 EAP=Stop 下会被包成 ErrorRecord 直接抛(pwsh 7
100
143
  // 不抛),一条 warning 就能让整条快照挂掉——与下方 add 段同款临时降级
101
144
  "$limitEap = $ErrorActionPreference",
102
145
  "$ErrorActionPreference = 'Continue'",
103
146
  "try {",
104
- // -z:路径含换行也不分裂(实测 PS 5.1/7 的 @() 都保留 NUL)
105
- " $limitFiles = @(((& $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files -z -co --exclude-standard 2>$null) -join '').Split([char]0))",
147
+ // -z:路径含换行也不分裂(实测 PS 5.1/7 的 @() 都保留 NUL);stderr 落文件
148
+ // 而不是 $null——失败原因是 issue #29 的第一诉求,不能再吞掉
149
+ " $limitFiles = @(((& $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files -z -co --exclude-standard 2>$limitErrFile) -join '').Split([char]0))",
106
150
  " $limitRc = $LASTEXITCODE",
107
151
  "} finally { $ErrorActionPreference = $limitEap }",
108
- "if ($limitRc -eq 0) {",
109
- " foreach ($limitRel in $limitFiles) {",
152
+ "if ($limitRc -ne 0) {",
153
+ " $limitMsg = ''",
154
+ // 只取首个非空行:PS 5.1 把 native stderr 写成 ErrorRecord(后续行是
155
+ // 调用点/脚本路径上下文),整段压平会把噪声塞进「最近错误」
156
+ " try {",
157
+ " $limitLines = @(Get-Content -LiteralPath $limitErrFile -Encoding UTF8 -ErrorAction SilentlyContinue | Where-Object { $_.Trim() })",
158
+ " if ($limitLines.Count -gt 0) { $limitMsg = ($limitLines[0] -replace '\\s+', ' ').Trim() }",
159
+ " } catch {}",
160
+ " Remove-Item -LiteralPath $limitErrFile -ErrorAction SilentlyContinue",
161
+ " if ($limitMsg.Length -gt 300) { $limitMsg = $limitMsg.Substring(0, 300) }",
162
+ " Write-Output ('SNAP_SCAN_FAIL ' + $limitRc)",
163
+ " Write-Output ('SNAP_SCAN_ERR ' + $limitMsg)",
164
+ " exit 0",
165
+ "}",
166
+ "Remove-Item -LiteralPath $limitErrFile -ErrorAction SilentlyContinue",
167
+ "foreach ($limitRel in $limitFiles) {",
110
168
  // 两道守卫吸收两类异常条目:5.1 的 Split 会留尾部空串;目录条目(嵌套仓库
111
169
  // 输出 `dir/`)的 Length 为空或抛——两者都不该计入(add 侧同样不纳入)
112
- " if (-not $limitRel) { continue }",
113
- " if (" + budget + " -gt 0 -and $limitSw.ElapsedMilliseconds -gt " + budget + ") { $limitHit = 'time'; break }",
114
- " try { $limitLen = [System.IO.FileInfo]::new($root + '/' + $limitRel).Length } catch { $limitLen = [long]0 }",
115
- // 口径 = 实际会进快照的字节:超过单文件上限的文件随后被 oversizeBlock
170
+ " if (-not $limitRel) { continue }",
171
+ " if (" + budget + " -gt 0 -and $limitSw.ElapsedMilliseconds -gt " + budget + ") { $limitHit = 'time'; break }",
172
+ " try { $limitLen = [System.IO.FileInfo]::new($root + '/' + $limitRel).Length } catch { $limitLen = [long]0 }",
173
+ // 口径 = 实际会进快照的字节:超过单文件上限的文件随后被 oversizeListBlock
116
174
  // 移出 index,不该计入总量(否则一个 300MB 视频就能把干净项目误判超限)
117
- " if ($limitLen -gt 0 -and $limitLen -le " + fileLimit + ") { $limitBytes += $limitLen }",
118
- " if (" + limit + " -gt 0 -and $limitBytes -gt " + limit + ") { $limitHit = 'bytes'; break }",
119
- " }",
175
+ " if ($limitLen -gt 0 -and $limitLen -le " + fileLimit + ") { $limitBytes += $limitLen }",
176
+ " if (" + limit + " -gt 0 -and $limitBytes -gt " + limit + ") { $limitHit = 'bytes'; break }",
120
177
  "}",
121
178
  "if ($limitHit -eq 'bytes') { Write-Output ('SNAP_LIMIT bytes ' + $limitBytes + ' ' + " + limit + "); exit 0 }",
122
179
  "if ($limitHit -eq 'time') { Write-Output ('SNAP_LIMIT time ' + $limitSw.ElapsedMilliseconds + ' ' + " + budget + "); exit 0 }"
@@ -246,7 +303,10 @@ function snapshotScript(root, store, gitExe, messageId, base) {
246
303
  excludeSyncBlock(store.excludeFile, base),
247
304
  // issue #24:总量/预算护栏先于 add——超限时连 add 都不跑
248
305
  limitScanBlock(store.maxWorkspaceBytes, store.maxFileBytes, store.scanBudgetMs),
306
+ // issue #29:大文件清单先于 add——add 带 exclude 后不再为它们写对象
307
+ oversizeListBlock(store.maxFileBytes),
249
308
  attrsMigrateBlock(),
309
+ oversizeAddPrepBlock(),
250
310
  // fail-open add(issue #7 加固):--ignore-errors 让「个别路径无法索引」
251
311
  // (无提交的嵌入式仓库、不可读文件等)以退出码 1 结束但索引照常落盘,
252
312
  // 快照缺个别路径可接受,好过整条快照 fatal。退出码 ≥2 才是真 fatal
@@ -260,13 +320,16 @@ function snapshotScript(root, store, gitExe, messageId, base) {
260
320
  // 后按 "unable to index file 'X'" 提取被跳过的路径,以 SNAP_SKIP 行
261
321
  // 回传 JS 侧做用户可见提示。
262
322
  "$ErrorActionPreference = 'Continue'",
263
- '$addLog = (@(& $git --git-dir=$g --work-tree=$root add -A --ignore-errors 2>&1) | ForEach-Object { [string]$_ }) -join "`n"',
323
+ '$addLog = (@(& $git --git-dir=$g --work-tree=$root add -A --ignore-errors @paArgs 2>&1) | ForEach-Object { [string]$_ }) -join "`n"',
264
324
  "$addRc = $LASTEXITCODE",
265
325
  "$ErrorActionPreference = 'Stop'",
266
326
  'if ($addRc -ge 2) { throw ("git add fatal (exit " + $addRc + "): " + $addLog) }',
267
327
  `foreach ($m in [regex]::Matches($addLog, "unable to index file '([^']+)'") ) { Write-Output ('SNAP_SKIP ' + $m.Groups[1].Value) }`,
268
328
  dropGitlinksBlock(),
269
- oversizeBlock(store.maxFileBytes),
329
+ // 兜底:旧 git 走裸 add 时大文件仍会被重新索引,这里按清单再清一次;
330
+ // pathspec 生效时它是空操作(清单条目本就不在 index)
331
+ oversizeRemoveBlock(),
332
+ oversizeCleanupBlock(),
270
333
  "$tree = (& $git --git-dir=$g --work-tree=$root write-tree).Trim()",
271
334
  "$commit = (& $git --git-dir=$g -c user.name=dsh-recall -c user.email=recall@dsh.local commit-tree $tree -m ('snapshot ' + " + psq(messageId) + ")).Trim()",
272
335
  "& $git --git-dir=$g tag -f " + psq("snap-" + messageId) + " $commit | Out-Null",
@@ -286,14 +349,19 @@ function diffScript(root, store, gitExe, tag, base, maxChanges) {
286
349
  "$root = " + psq(root),
287
350
  dropGitlinksBlock(),
288
351
  excludeSyncBlock(store.excludeFile, base),
352
+ // issue #29:与 snapshotScript 同形——大文件不进 add(否则 preview 一次
353
+ // 也要为它们写对象),清单随 add 后的兜底剔除一起收口
354
+ oversizeListBlock(store.maxFileBytes),
355
+ oversizeAddPrepBlock(),
289
356
  // fail-open add:语义同 snapshotScript(--ignore-errors 跳过无法索引的
290
357
  // 路径、≥2 显式 throw 防「旧索引假成功」);此处不提取 SNAP_SKIP——
291
358
  // 被跳过的路径不进索引,diff 天然不显示、rollback 的删除清单来自当前
292
359
  // 索引也天然不会误删它们
293
- "& $git --git-dir=$g --work-tree=$root add -A --ignore-errors",
360
+ "& $git --git-dir=$g --work-tree=$root add -A --ignore-errors @paArgs",
294
361
  'if ($LASTEXITCODE -ge 2) { throw ("git add fatal (exit " + $LASTEXITCODE + ")") }',
295
362
  dropGitlinksBlock(),
296
- oversizeBlock(store.maxFileBytes),
363
+ oversizeRemoveBlock(),
364
+ oversizeCleanupBlock(),
297
365
  "$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage",
298
366
  // 旧 tag 的树里可能仍有 gitlink(修复前留下的),从目标侧一并剔除,
299
367
  // 否则 diff 会报出“恢复 dsh-recall-plugin”这类幻影条目
@@ -335,11 +403,15 @@ function rollbackScript(root, store, gitExe, tag, base) {
335
403
  "$root = " + psq(root),
336
404
  dropGitlinksBlock(),
337
405
  excludeSyncBlock(store.excludeFile, base),
406
+ // issue #29:同 snapshotScript(回退前的当前清单同样不该为大文件写对象)
407
+ oversizeListBlock(store.maxFileBytes),
408
+ oversizeAddPrepBlock(),
338
409
  // fail-open add:语义同 snapshotScript 同款注释(diff/rollback 复用)
339
- "& $git --git-dir=$g --work-tree=$root add -A --ignore-errors",
410
+ "& $git --git-dir=$g --work-tree=$root add -A --ignore-errors @paArgs",
340
411
  'if ($LASTEXITCODE -ge 2) { throw ("git add fatal (exit " + $LASTEXITCODE + ")") }',
341
412
  dropGitlinksBlock(),
342
- oversizeBlock(store.maxFileBytes),
413
+ oversizeRemoveBlock(),
414
+ oversizeCleanupBlock(),
343
415
  // 同 diffScript:-z 的 NUL 输出会被 PowerShell 捕获丢弃,改为逐行 + quotePath=false
344
416
  "$curOut = & $git -c core.quotePath=false --git-dir=$g --work-tree=$root ls-files --stage",
345
417
  "$targetOut = @(& $git -c core.quotePath=false --git-dir=$g ls-tree -r " + psq(tag) + " | Where-Object { -not $_.StartsWith('160000') })",
package/lib/snapshots.js CHANGED
@@ -21,11 +21,27 @@ function parseSnapshotLimit(out) {
21
21
  }
22
22
  return null;
23
23
  }
24
+ function parseScanFail(out) {
25
+ let rc = null;
26
+ let message = "";
27
+ for (const line of String(out || "").split(/\r?\n/)) {
28
+ if (line.indexOf("SNAP_SCAN_FAIL ") === 0) {
29
+ const n = parseInt(line.slice(15).trim(), 10);
30
+ if (Number.isFinite(n)) rc = n;
31
+ } else if (line.indexOf("SNAP_SCAN_ERR ") === 0) {
32
+ message = line.slice(14).trim();
33
+ }
34
+ }
35
+ return rc === null ? null : { rc, message };
36
+ }
24
37
  function bytesText(bytes) {
25
38
  if (bytes >= 1048576) return Math.round(bytes / 1048576) + " MB";
26
39
  return Math.max(1, Math.round(bytes / 1024)) + " KB";
27
40
  }
28
41
  function buildSnapshotLimitNotice(limit) {
42
+ if (limit.kind === "scan") {
43
+ return "\u65E0\u6CD5\u7EDF\u8BA1\u8BE5\u5DE5\u4F5C\u533A\u7684\u5FEB\u7167\u8303\u56F4\uFF08git \u9000\u51FA\u7801 " + limit.value + "\uFF09\uFF0C\u672C\u6B21\u5FEB\u7167\u5DF2\u8DF3\u8FC7\uFF1B\u8BE5\u5DE5\u4F5C\u533A\u6682\u505C\u5FEB\u7167\u7EA6 10 \u5206\u949F\uFF0C\u4FEE\u590D\u540E\u81EA\u52A8\u6062\u590D\uFF0C\u8BE6\u60C5\u89C1\u300C\u6700\u8FD1\u9519\u8BEF\u300D";
44
+ }
29
45
  if (limit.kind === "bytes") {
30
46
  return "\u8BE5\u5DE5\u4F5C\u533A\u53EF\u7EB3\u5165\u5FEB\u7167\u7684\u6587\u4EF6\u603B\u91CF\u8D85\u8FC7\u4E0A\u9650\uFF08\u7EA6 " + bytesText(limit.value) + " / " + bytesText(limit.limit) + "\uFF0C\u5DF2\u6309 .gitignore \u4E0E\u6392\u9664\u8868\u8FC7\u6EE4\uFF09\uFF0C\u5DF2\u505C\u7528\u5FEB\u7167\uFF1B\u53EF\u8C03\u5927\u300C\u5DE5\u4F5C\u533A\u603B\u91CF\u4E0A\u9650\u300D\u6216\u8865\u5145\u6392\u9664\u8868\u540E\u81EA\u52A8\u6062\u590D";
31
47
  }
@@ -185,10 +201,20 @@ function createSnapshots(ctx, rt, config) {
185
201
  const FUSE_AFTER = 3;
186
202
  const FUSE_BACKOFF_BASE_MS = 5 * 60 * 1e3;
187
203
  const FUSE_BACKOFF_CAP_MS = 60 * 60 * 1e3;
204
+ const EXPENSIVE_FAIL_MS = 60 * 1e3;
188
205
  const snapFailures = /* @__PURE__ */ new Map();
189
206
  const SNAP_LIMIT_RETRY_MS = 10 * 60 * 1e3;
190
207
  const snapLimits = /* @__PURE__ */ new Map();
191
208
  const limitBlockedAt = /* @__PURE__ */ new Map();
209
+ function markSnapshotLimited(root, limit) {
210
+ snapLimits.set(root, { ...limit, at: Date.now(), maxWorkspaceBytes: config.maxWorkspaceBytes, scanBudgetMs: config.scanBudgetMs });
211
+ }
212
+ function noteSnapshotBlocked(root, detail) {
213
+ const last = limitBlockedAt.get(root) || 0;
214
+ if (Date.now() - last < 5 * 60 * 1e3) return;
215
+ limitBlockedAt.set(root, Date.now());
216
+ rt.recordError(detail);
217
+ }
192
218
  const SUPPORTED_FORMAT = 1;
193
219
  const FORMAT_OK_TTL_MS = 3e4;
194
220
  const formatOkAt = /* @__PURE__ */ new Map();
@@ -413,20 +439,27 @@ function createSnapshots(ctx, rt, config) {
413
439
  return;
414
440
  }
415
441
  await loadIndex(root, sessionId);
442
+ const captureStartedAt = Date.now();
416
443
  try {
417
- const out = await rt.runShell(S.snapshotScript(root, store, state.gitExe || "", messageId, BASE()), { timeoutMs: 6e5, stdoutMaxBytes: 65536 });
444
+ const budget = config.snapshotBudgetMs > 0 ? config.snapshotBudgetMs : 864e5;
445
+ const out = await rt.runShell(S.snapshotScript(root, store, state.gitExe || "", messageId, BASE()), { timeoutMs: budget, stdoutMaxBytes: 65536 });
418
446
  const limit = parseSnapshotLimit(out);
419
447
  if (limit) {
420
- snapLimits.set(root, { ...limit, at: Date.now(), maxWorkspaceBytes: config.maxWorkspaceBytes, scanBudgetMs: config.scanBudgetMs });
448
+ markSnapshotLimited(root, limit);
421
449
  const notice = buildSnapshotLimitNotice(limit);
422
- const last = limitBlockedAt.get(root) || 0;
423
- if (Date.now() - last >= 5 * 60 * 1e3) {
424
- limitBlockedAt.set(root, Date.now());
425
- rt.recordError("recall snapshot disabled for " + root + ": " + notice);
426
- }
450
+ noteSnapshotBlocked(root, "recall snapshot disabled for " + root + ": " + notice);
427
451
  setFeedback(messageId, { failed: true, kind: "workspaceLimit", error: notice });
428
452
  return;
429
453
  }
454
+ const scanFail = parseScanFail(out);
455
+ if (scanFail) {
456
+ const pseudo = { kind: "scan", value: scanFail.rc, limit: 0 };
457
+ markSnapshotLimited(root, pseudo);
458
+ const notice = buildSnapshotLimitNotice(pseudo);
459
+ noteSnapshotBlocked(root, "recall snapshot scan failed for " + root + ": rc=" + scanFail.rc + " " + scanFail.message);
460
+ setFeedback(messageId, { failed: true, kind: "scanFail", error: notice });
461
+ return;
462
+ }
430
463
  snapFailures.delete(root);
431
464
  state.snapshots.set(String(messageId), { root, time: time || Date.now(), sessionId });
432
465
  await saveIndex(root, sessionId);
@@ -434,7 +467,7 @@ function createSnapshots(ctx, rt, config) {
434
467
  } catch (error) {
435
468
  rt.recordError("recall snapshot failed: " + String(error));
436
469
  setFeedback(messageId, { failed: true, ...buildFeedbackError(String(error)) });
437
- await handleSnapshotFailure(root, store);
470
+ await handleSnapshotFailure(root, store, Date.now() - captureStartedAt);
438
471
  }
439
472
  }
440
473
  function setFeedback(messageId, rec) {
@@ -460,7 +493,7 @@ function createSnapshots(ctx, rt, config) {
460
493
  }
461
494
  return {};
462
495
  }
463
- async function handleSnapshotFailure(root, store) {
496
+ async function handleSnapshotFailure(root, store, elapsedMs = 0) {
464
497
  if (store && state.gitExe) {
465
498
  try {
466
499
  await rt.runShell(S.pruneScript(store, state.gitExe), { timeoutMs: 6e5, stdoutMaxBytes: 4096 });
@@ -470,11 +503,15 @@ function createSnapshots(ctx, rt, config) {
470
503
  }
471
504
  const f = snapFailures.get(root) || { count: 0, skipUntil: 0 };
472
505
  f.count++;
506
+ const expensive = elapsedMs >= EXPENSIVE_FAIL_MS;
507
+ if (expensive && f.count < FUSE_AFTER) f.count = FUSE_AFTER;
473
508
  if (f.count >= FUSE_AFTER) {
474
509
  const backoff = Math.min(FUSE_BACKOFF_BASE_MS * 2 ** (f.count - FUSE_AFTER), FUSE_BACKOFF_CAP_MS);
475
510
  const wasFused = Date.now() < f.skipUntil;
476
511
  f.skipUntil = Date.now() + backoff;
477
- if (!wasFused) rt.recordError("recall snapshot fused after " + f.count + " consecutive failures, backoff " + Math.round(backoff / 6e4) + "min for: " + root);
512
+ if (!wasFused) {
513
+ rt.recordError("recall snapshot fused after " + f.count + " consecutive failures, backoff " + Math.round(backoff / 6e4) + "min for: " + root + (expensive ? " (last failure took " + Math.round(elapsedMs / 1e3) + "s)" : ""));
514
+ }
478
515
  }
479
516
  snapFailures.set(root, f);
480
517
  }
@@ -579,6 +616,7 @@ export {
579
616
  judgeStoreFormat,
580
617
  parseChanges,
581
618
  parseDiffOutput,
619
+ parseScanFail,
582
620
  parseSkipped,
583
621
  parseSnapshotLimit,
584
622
  parseTagsWithTime,
package/lib/store.js CHANGED
@@ -149,7 +149,6 @@ function createRuntime(ctx, config, deps) {
149
149
  roots: /* @__PURE__ */ new Map(),
150
150
  stores: /* @__PURE__ */ new Map(),
151
151
  snapshots: /* @__PURE__ */ new Map(),
152
- queue: Promise.resolve(),
153
152
  indexLoaded: /* @__PURE__ */ new Set(),
154
153
  // PF-5 索引终态三/四档标记(rebuildOrphans 守卫的数据源):
155
154
  // - indexHealthy:磁盘索引解析成功且在场(loadIndex 正常载入分支)——
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "2.5.2",
3
+ "version": "2.6.0",
4
4
  "description": "在 DSH 中撤回自己发过的消息:工作区文件与对话历史一起回到那条消息发出之前,消息的文本与附件自动放回输入框,改完即可重发。文件走独立影子 git 快照(不碰项目自身的 git),对话走官方 sessions.fork,原会话归档可找回。",
5
5
  "icon": "assets/icon.svg",
6
6
  "type": "module",