dsh-recall-plugin 2.5.3 → 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,24 @@
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
+
7
25
  ## [2.5.3] - 2026-10-30
8
26
 
9
27
  ### 修复
package/README.en.md CHANGED
@@ -158,6 +158,7 @@ All options can be edited visually in the "**Settings → Plugin Config → Reca
158
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 |
package/README.md CHANGED
@@ -155,6 +155,7 @@ peerDependencies 按 minor 版本线开窗,与 `dsh.compatibility.dshReleases`
155
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 的),这类目录也没有回退价值;删掉对应项即恢复 |
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
  }
@@ -495,6 +495,10 @@ function excludeDumpScript(files) {
495
495
  lines.push(
496
496
  "printf 'EXCLBEGIN %s\\n' " + q,
497
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"',
498
502
  "echo 'EXCLEND'"
499
503
  );
500
504
  }
@@ -537,10 +541,16 @@ function storesDumpScript(container, extraDirs) {
537
541
  ' echo "ROOT "',
538
542
  " fi",
539
543
  " echo INDEXBEGIN",
540
- ' 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"',
541
550
  " echo INDEXEND",
542
551
  " echo LINEAGEBEGIN",
543
- ' cat "$d/lineage.json" 2>/dev/null',
552
+ ' cat "$d/lineage.json" 2>/dev/null || true',
553
+ ' printf "\\n"',
544
554
  " echo LINEAGEEND",
545
555
  "done",
546
556
  "exit 0"
package/lib/snapshots.js CHANGED
@@ -441,7 +441,8 @@ function createSnapshots(ctx, rt, config) {
441
441
  await loadIndex(root, sessionId);
442
442
  const captureStartedAt = Date.now();
443
443
  try {
444
- 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 });
445
446
  const limit = parseSnapshotLimit(out);
446
447
  if (limit) {
447
448
  markSnapshotLimited(root, limit);
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.3",
3
+ "version": "2.6.0",
4
4
  "description": "在 DSH 中撤回自己发过的消息:工作区文件与对话历史一起回到那条消息发出之前,消息的文本与附件自动放回输入框,改完即可重发。文件走独立影子 git 快照(不碰项目自身的 git),对话走官方 sessions.fork,原会话归档可找回。",
5
5
  "icon": "assets/icon.svg",
6
6
  "type": "module",