dsh-any-agent 0.5.2 → 0.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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 工作区路径边界:哪些 cwd 不能拿来当工作区(家目录本身、文件系统根)。
3
+ *
4
+ * 从 hub.mjs 拆出来:只看路径与当前文件系统,不依赖 Hub/Conversation 状态。
5
+ * hub.mjs 仍然重新导出 `homeWorkspaceProblem` / `fsCaseInsensitive`,外部的导入路径不变。
6
+ */
7
+
8
+ import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { homedir, tmpdir } from 'node:os';
10
+ import { join, parse, resolve, sep } from 'node:path';
11
+
12
+ /**
13
+ * 当前文件系统是否大小写不敏感(缓存一次)。
14
+ *
15
+ * 不能按平台猜:GitHub 的 `macos-latest` 是**大小写敏感的 APFS**,而普通 macOS
16
+ * 默认又不敏感;Windows 一定不敏感;Linux 一定敏感。用临时文件探针最稳。
17
+ */
18
+ let caseInsensitiveCache = null;
19
+ export function fsCaseInsensitive() {
20
+ if (caseInsensitiveCache !== null) return caseInsensitiveCache;
21
+ if (process.platform === 'win32') {
22
+ caseInsensitiveCache = true;
23
+ return true;
24
+ }
25
+ try {
26
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-any-agent-case-'));
27
+ writeFileSync(join(dir, 'probe'), '');
28
+ caseInsensitiveCache = existsSync(join(dir, 'PROBE'));
29
+ rmSync(dir, { recursive: true, force: true });
30
+ } catch {
31
+ caseInsensitiveCache = false;
32
+ }
33
+ return caseInsensitiveCache;
34
+ }
35
+
36
+ /**
37
+ * 工作区能不能用在家目录本身?
38
+ *
39
+ * DSH 自己的 ACL 沙箱要求临时目录在工作区**之外**。Windows 上 %TEMP% 是
40
+ * C:/Users/<name>/AppData/Local/Temp,正好在用户家目录里面,所以把家目录
41
+ * 当作工作区时,沙箱初始化会失败:
42
+ * Windows ACL: temp root must be outside the workspace
43
+ * 症状出现在第一次起 shell 的时候,看起来像这个服务坏了,其实只是 cwd
44
+ * 选得太大。read-only 档不写盘、也不起 ACL 沙箱,所以不受影响。
45
+ *
46
+ * @returns {string|null} null 表示没问题,否则返回一段可直接抛出的说明。
47
+ */
48
+ export function homeWorkspaceProblem(cwd, permission) {
49
+ if (permission === 'read-only') return null;
50
+ const home = homedir();
51
+ if (!home) return null;
52
+ const caseInsensitive = process.platform === 'win32' || fsCaseInsensitive();
53
+ const norm = (p) => {
54
+ // 解析 symlink:一个指向家目录/根目录的软链不能绕过这两个边界。
55
+ // 路径不存在时(测试里的 "some-project")回落到 resolve。
56
+ let r;
57
+ try {
58
+ r = realpathSync(p);
59
+ } catch {
60
+ r = resolve(p);
61
+ }
62
+ return caseInsensitive ? r.toLowerCase() : r;
63
+ };
64
+ // 根目录 = 整个磁盘。workspace-write 的沙箱根会跟着 cwd 走,
65
+ // 于是“只写工作区”退化成“可写全盘”,必须显式要求 danger-full-access。
66
+ const fsRoot = resolve(parse(cwd).root);
67
+ if (norm(cwd) === norm(fsRoot)) {
68
+ return [
69
+ 'cwd 不能是文件系统根目录: ' + cwd,
70
+ ' workspace-write 的沙箱根会变成整个磁盘,等于绕过“只写工作区”的权限档。',
71
+ ' 请传一个具体的项目目录;确实要全盘访问请显式用 permission=danger-full-access。',
72
+ ].join('\n');
73
+ }
74
+ if (norm(cwd) !== norm(home)) return null;
75
+
76
+ const temp = tmpdir();
77
+ const tempInside = norm(temp) === norm(home) || norm(temp).startsWith(norm(home) + sep);
78
+ const lines = ['cwd 不能是用户家目录本身: ' + cwd];
79
+ if (tempInside) lines.push(' 临时目录 ' + temp + ' 就在它里面,');
80
+ lines.push(
81
+ ' DSH 的 ACL 沙箱要求临时目录在工作区之外,否则第一次起 shell 就会报:',
82
+ ' Windows ACL: temp root must be outside the workspace',
83
+ '',
84
+ ' 换个具体项目目录最省事,或者改权限档:',
85
+ ' permission=read-only 不写盘,不需要该沙箱',
86
+ ' permission=danger-full-access 不受该沙箱约束',
87
+ );
88
+ return lines.join('\n');
89
+ }
package/src/workspace.mjs CHANGED
@@ -15,16 +15,22 @@
15
15
  * 或手动执行回填时做一次读-改-写,窗口很小;但仍与 GUI 存在理论上的覆盖可能。
16
16
  */
17
17
 
18
- import { copyFileSync, createReadStream, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, readdirSync, rmSync, realpathSync } from 'node:fs';
18
+ import { copyFileSync, createReadStream, readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, readdirSync, rmSync, realpathSync, statSync } from 'node:fs';
19
19
  import { randomUUID } from 'node:crypto';
20
20
  import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
21
21
  import { tmpdir } from 'node:os';
22
22
  import { StringDecoder } from 'node:string_decoder';
23
23
  import { createZstdDecompress, zstdDecompressSync } from 'node:zlib';
24
- import { DSH_HOME, REGISTER_WORKSPACE } from './config.mjs';
24
+ import { DSH_HOME, PKG_ROOT, REGISTER_WORKSPACE } from './config.mjs';
25
25
 
26
26
  const WORKSPACE_FILE = join(DSH_HOME, 'storages', 'workspace.json');
27
27
  const SESSIONS_ROOT = join(DSH_HOME, 'sessions');
28
+ /**
29
+ * DSH 的会话投影缓存:每个会话一个 `<id>.json`(标题/用量/cwd 等折叠结果)。
30
+ * ★ 删会话目录**不会**连带删它 —— 实测测试跑多了这里会攒出几百个孤儿文件
31
+ * (磁盘上的会话早没了,缓存还在),所以清理会话必须一并删它。
32
+ */
33
+ const PROJCACHE_DIR = join(DSH_HOME, 'storages', 'session_projcache', 'sessions');
28
34
  /** forget 墓碑目录(存 id+时间,不含内容);清掉测试会话时一并清掉其墓碑。 */
29
35
  const FORGOTTEN_DIR = process.env.DSH_MCP_FORGOTTEN_DIR ?? join(DSH_HOME, '.dsh-any-agent-forgotten');
30
36
 
@@ -99,6 +105,42 @@ export function isTestDesktopPath(p) {
99
105
  return /[\\/]Desktop[\\/](dsh-any-agent-test-[AB]|dsh-conc-[ABC])([\\/]|$)/i.test(String(p ?? ''));
100
106
  }
101
107
 
108
+ /**
109
+ * 边界测试(boundary.mjs)为了验证"非临时目录会被登记",在**本仓库自己的** `.state/` 下
110
+ * 造一个 `ws-probe-<毫秒时间戳>` 目录。它不在系统临时目录里,所以单独认这一种。
111
+ *
112
+ * ★ 绑定 PKG_ROOT(当前进程跑的这份代码所在的仓库/worktree)而不是任意 `.state`:
113
+ * 并行跑测试的别的 worktree 有它们自己的 `.state/ws-probe-*`,那是人家还活着的会话,
114
+ * 这里的清理绝不能动。
115
+ */
116
+ export function isTestScratchPath(p) {
117
+ try {
118
+ if (!p || !isAbsolute(String(p))) return false;
119
+ const rel = relative(join(PKG_ROOT, '.state'), String(p));
120
+ if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel)) return false;
121
+ return /^ws-probe-\d+$/.test(rel.split(/[\\/]/).filter(Boolean)[0] ?? '');
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ /** 会话清理只认这个:桌面 `dsh-any-agent-test-A|B`(注意不含 `dsh-conc-*`,那只用于清工作区登记)。 */
128
+ const DESKTOP_TEST_SESSION_RE = /[\\/]Desktop[\\/]dsh-any-agent-test-[AB]([\\/]|$)/i;
129
+
130
+ /**
131
+ * ★ 清理会话的**唯一护栏**:只有 cwd 满足下面三种之一的会话才允许被删,
132
+ * 不论是被"扫描命中"、被 `ids` 点名还是被 `roots` 圈中 —— 点名也绕不过。
133
+ * 1) 当前进程 `os.tmpdir()/dsh-any-agent-*`(各测试套件自己的临时根)
134
+ * 2) 桌面 `dsh-any-agent-test-A|B`(验收/工作区效果测试)
135
+ * 3) 本仓库 `.state/ws-probe-*`(边界测试的登记探针)
136
+ * 别的 agent / 别的 worktree 把 TMP 指到 `C:\dsh-agent-tmp\...` 时,它们的会话对本进程
137
+ * 就**不是**测试目录 —— 这正是想要的:谁造的谁清。
138
+ */
139
+ export function isTestSessionCwd(p) {
140
+ const s = String(p ?? '');
141
+ return isTestTempPath(s) || DESKTOP_TEST_SESSION_RE.test(s) || isTestScratchPath(s);
142
+ }
143
+
102
144
  /**
103
145
  * 是否该登记这个工作区。
104
146
  * 模式(DSH_MCP_REGISTER_WORKSPACE):project(默认,跳过系统临时目录)| all | 0(关闭)
@@ -425,65 +467,199 @@ export function backfillWorkspaces({ includeTemp = false, dryRun = false, pruneM
425
467
  return stats;
426
468
  }
427
469
 
470
+ const SAFE_SESSION_ID = /^[A-Za-z0-9._-]{4,80}$/;
471
+
472
+ /**
473
+ * 列出磁盘上所有会话(带会话头里的 id/cwd/父会话)。
474
+ * 头读不出来的目录直接略过:认不出 cwd 的会话**绝不当成测试会话**。
475
+ */
476
+ function listSessionEntries() {
477
+ const entries = [];
478
+ if (!existsSync(SESSIONS_ROOT)) return entries;
479
+ for (const bucket of readdirSync(SESSIONS_ROOT)) {
480
+ const bucketPath = join(SESSIONS_ROOT, bucket);
481
+ let dirs;
482
+ try {
483
+ dirs = readdirSync(bucketPath, { withFileTypes: true });
484
+ } catch {
485
+ continue;
486
+ }
487
+ for (const d of dirs) {
488
+ // 符号链接一律不碰:rmSync 对链接的行为依赖平台,且链接目标可能不在会话目录内。
489
+ if (!d.isDirectory() || d.isSymbolicLink()) continue;
490
+ const dir = join(bucketPath, d.name);
491
+ // 兜底:只允许删 SESSIONS_ROOT 下面的目录(结构上已经如此,防未来改动)。
492
+ if (!isInside(SESSIONS_ROOT, dir)) continue;
493
+ const header = readHeader(join(dir, 'session.v4.jsonl.zstd'));
494
+ if (!header?.id) continue;
495
+ entries.push({
496
+ id: header.id,
497
+ dirName: d.name,
498
+ dir,
499
+ bucketPath,
500
+ cwd: typeof header.cwd === 'string' ? header.cwd : '',
501
+ origin: header.origin ?? null,
502
+ parent: header.parentSession ?? null,
503
+ });
504
+ }
505
+ }
506
+ return entries;
507
+ }
508
+
509
+ /**
510
+ * 把会话 id 从 DSH 的工作区注册表里摘掉(**定点**:只动这些 id,不重新扫描磁盘)。
511
+ *
512
+ * 为什么不再用 `backfillWorkspaces({ includeTemp: true })` 来"对账":回填会把磁盘上
513
+ * **所有**会话重新登记一遍(连别的进程/别的 agent 还活着的临时会话都会被登记成工作区,
514
+ * 出现在 GUI 侧栏里)——实测并行跑测试时真实 workspace.json 里就多出了别人的测试工作区。
515
+ *
516
+ * 规则:
517
+ * - 从各工作区的 sessionIds、archivedSessionIds、pinnedSessionIds 摘掉这些 id;
518
+ * - 摘完变空、且路径是测试目录的工作区条目一并删掉(真实项目的工作区条目绝不删);
519
+ * - 没有任何变化就**不写盘**(临时目录的测试会话根本没登记过,这是常态);
520
+ * - 注册表坏了 fail-closed:返回 error,不写。
521
+ */
522
+ export function unregisterSessions(ids, { dryRun = false } = {}) {
523
+ const set = new Set((ids ?? []).filter((x) => typeof x === 'string' && x));
524
+ const res = { changed: false, sessions_removed: 0, workspaces_dropped: [], error: null };
525
+ if (!set.size) return res;
526
+ let doc;
527
+ try {
528
+ doc = readDoc();
529
+ } catch (e) {
530
+ return { ...res, error: e.message };
531
+ }
532
+ for (const wsId of Object.keys(doc.tables.workspaces)) {
533
+ const ws = doc.tables.workspaces[wsId];
534
+ if (!Array.isArray(ws?.sessionIds)) continue;
535
+ const kept = ws.sessionIds.filter((sid) => !set.has(sid));
536
+ const removed = ws.sessionIds.length - kept.length;
537
+ if (!removed) continue;
538
+ res.sessions_removed += removed;
539
+ ws.sessionIds = kept;
540
+ const isTestWs = isTestTempPath(ws.path) || isTestDesktopPath(ws.path) || isTestScratchPath(ws.path);
541
+ if (kept.length === 0 && isTestWs) {
542
+ res.workspaces_dropped.push({ id: wsId, path: ws.path });
543
+ delete doc.tables.workspaces[wsId];
544
+ doc.global.workspaceIds = (doc.global.workspaceIds ?? []).filter((x) => x !== wsId);
545
+ if (doc.global.defaultWorkspaceId === wsId) doc.global.defaultWorkspaceId = null;
546
+ }
547
+ }
548
+ for (const key of ['archivedSessionIds', 'pinnedSessionIds']) {
549
+ const before = doc.global[key] ?? [];
550
+ const after = before.filter((sid) => !set.has(sid));
551
+ if (after.length !== before.length) {
552
+ res.sessions_removed += before.length - after.length;
553
+ doc.global[key] = after;
554
+ }
555
+ }
556
+ res.changed = res.sessions_removed > 0;
557
+ if (res.changed && !dryRun) {
558
+ try {
559
+ writeDoc(doc);
560
+ } catch (e) {
561
+ res.error = e.message;
562
+ }
563
+ }
564
+ return res;
565
+ }
566
+
428
567
  /**
429
568
  * 删除测试会话(**这是唯一会动磁盘会话文件的函数**,所以规则写得很死)。
430
569
  *
431
- * 只会删这几类,其余一律不碰:
432
- * 1) testOnly:cwd 在 `tmpdir()/dsh-any-agent-*` 下(本仓库测试自己造的)
433
- * 2) cwd 匹配 Desktop\dsh-any-agent-test-*
434
- * 3) tempOnly:cwd 在系统临时目录下(显式人工命令才用,范围最大)
435
- * 4) ids 里显式指定的
570
+ * ★ 硬护栏:无论怎么选中,一个会话只有在它**记录的 cwd** 是测试目录时才会被删
571
+ * (见 `isTestSessionCwd`)。`ids` 点名、`roots` 圈定都绕不过这条;cwd 不是测试目录的
572
+ * 会被列进 `skipped` 并拒绝。以前 `ids` 可以删任意 cwd 的会话,写错一个 id 就是事故。
573
+ *
574
+ * 选中方式:
575
+ * - 默认(扫荡):磁盘上所有 cwd 是测试目录的会话,外加 `ids` 点名的。
576
+ * - `idsOnly` 或给了 `roots`(点名模式):只删 `ids` 点名的、以及 cwd 落在 `roots` 里的。
577
+ * 测试套件收尾用这个——只清自己造的,不会把并行进程的会话一起带走。
578
+ * - 无论哪种,被选中会话的**子会话**(`parentSession` 指向它,DSH 派子代理时产生)
579
+ * 会级联带走(同样要过 cwd 护栏):测试不知道子会话的 id,不级联就会一直留着。
580
+ *
581
+ * 每个被删会话清掉的 DSH 侧产物:会话目录、投影缓存 `<id>.json`、forget 墓碑、
582
+ * 工作区注册里的登记(定点摘除)、因此变空的分桶目录。
583
+ * 本项目自己的 per-run 数据(注册表/哨兵/审核/锁/元数据)见 `src/residue.mjs`。
436
584
  *
437
585
  * @param {object} opts
438
- * @param {boolean} [opts.testOnly] 只删**本仓库测试造的**临时目录(`tmpdir()/dsh-any-agent-*`)
439
- * 与桌面 `dsh-any-agent-test-*`。自动清理(测试套件收尾)必须用这条。
440
- * @param {boolean} [opts.tempOnly] 删除**所有** cwd 在系统临时目录下的会话。
441
- * 只给显式人工命令用;不要放进自动清理 —— 用户完全可能把真实项目放在临时目录。
442
- * @param {boolean} [opts.idsOnly] **只删 ids 里点名的**,忽略上面三条规则。
443
- * 要精确删某几个会话时用这条(否则一条临时目录规则会把别的也一起带走 —— 真实踩过)。
586
+ * @param {boolean} [opts.dryRun] 只列清单,不删任何东西
587
+ * @param {string[]} [opts.ids] 点名的会话 id
588
+ * @param {string[]} [opts.roots] 点名模式:cwd 在这些目录里的会话
589
+ * @param {boolean} [opts.idsOnly] 点名模式(只认 ids/roots,不扫荡)
590
+ * @param {boolean} [opts.testOnly] 兼容旧调用,忽略:护栏现在永远开着
444
591
  */
445
- export function purgeTestSessions({ dryRun = false, ids = [], testOnly = false, tempOnly = false, idsOnly = false } = {}) {
446
- const explicit = new Set(ids);
447
- const victims = [];
448
- if (existsSync(SESSIONS_ROOT)) {
449
- for (const bucket of readdirSync(SESSIONS_ROOT)) {
450
- const bucketPath = join(SESSIONS_ROOT, bucket);
451
- let dirs;
452
- try {
453
- dirs = readdirSync(bucketPath, { withFileTypes: true });
454
- } catch {
592
+ export function purgeTestSessions({ dryRun = false, ids = [], roots = [], idsOnly = false } = {}) {
593
+ const explicit = new Set((ids ?? []).filter((x) => typeof x === 'string' && x));
594
+ const rootList = (roots ?? []).filter((r) => typeof r === 'string' && r && isAbsolute(r));
595
+ const scoped = idsOnly || rootList.length > 0;
596
+ const all = listSessionEntries();
597
+ const victims = new Map();
598
+ const skipped = [];
599
+ const skippedIds = new Set();
600
+
601
+ const refuse = (e, reason) => {
602
+ if (skippedIds.has(e.id)) return;
603
+ skippedIds.add(e.id);
604
+ skipped.push({ id: e.id, cwd: e.cwd, reason });
605
+ };
606
+ const eligibleShape = (e) => SAFE_SESSION_ID.test(e.id) && e.id === e.dirName;
607
+ const take = (e, reason) =>
608
+ victims.set(e.id, { id: e.id, dir: e.dir, bucketPath: e.bucketPath, cwd: e.cwd, reason, origin: e.origin, parent: e.parent });
609
+
610
+ for (const e of all) {
611
+ const named = explicit.has(e.id);
612
+ const inRoot = Boolean(e.cwd) && isAbsolute(e.cwd) && rootList.some((r) => isInside(r, e.cwd));
613
+ const swept = !scoped && isTestSessionCwd(e.cwd);
614
+ if (!named && !inRoot && !swept) continue;
615
+ if (!isTestSessionCwd(e.cwd)) {
616
+ refuse(e, 'cwd 不是测试目录,拒绝删除');
617
+ continue;
618
+ }
619
+ if (!eligibleShape(e)) {
620
+ refuse(e, '会话 id 与目录名不一致或形状非法');
621
+ continue;
622
+ }
623
+ take(e, named ? '显式指定' : inRoot ? '测试根目录' : DESKTOP_TEST_SESSION_RE.test(e.cwd) ? '验收测试目录' : '测试临时目录');
624
+ }
625
+
626
+ // 子会话级联(可传递):父会话被选中,它派出的子代理会话一并算。
627
+ for (let grew = true; grew; ) {
628
+ grew = false;
629
+ for (const e of all) {
630
+ if (victims.has(e.id) || !e.parent || !victims.has(e.parent)) continue;
631
+ if (!isTestSessionCwd(e.cwd)) {
632
+ refuse(e, '子会话的 cwd 不是测试目录,拒绝删除');
455
633
  continue;
456
634
  }
457
- for (const d of dirs) {
458
- // 符号链接一律不碰:rmSync 对链接的行为依赖平台,且链接目标可能不在会话目录内。
459
- if (!d.isDirectory() || d.isSymbolicLink()) continue;
460
- const dir = join(bucketPath, d.name);
461
- // 兜底:只允许删 SESSIONS_ROOT 下面的目录(结构上已经如此,防未来改动)。
462
- if (!isInside(SESSIONS_ROOT, dir)) continue;
463
- const header = readHeader(join(dir, 'session.v4.jsonl.zstd'));
464
- if (!header?.id) continue;
465
- const cwd = header.cwd ?? '';
466
- const isTemp = testOnly ? isTestTempPath(cwd) : isTempPath(cwd);
467
- const isTestDesktop = !tempOnly && /[\\/]Desktop[\\/]dsh-any-agent-test-[AB]([\\/]|$)/i.test(cwd);
468
- const isExplicit = explicit.has(header.id);
469
- // idsOnly:只认点名,别的规则全部作废
470
- if (idsOnly ? !isExplicit : !isTemp && !isTestDesktop && !isExplicit) continue;
471
- victims.push({
472
- id: header.id,
473
- dir,
474
- cwd,
475
- reason: isExplicit ? '显式指定' : isTestDesktop ? '验收测试目录' : testOnly ? '测试临时目录' : '临时目录',
476
- origin: header.origin ?? null,
477
- });
635
+ if (!eligibleShape(e)) {
636
+ refuse(e, '会话 id 与目录名不一致或形状非法');
637
+ continue;
478
638
  }
639
+ take(e, '子会话(随父会话)');
640
+ grew = true;
479
641
  }
480
642
  }
643
+
644
+ const list = [...victims.values()];
481
645
  if (!dryRun) {
482
- for (const v of victims) {
646
+ const removedIds = [];
647
+ const touchedBuckets = new Set();
648
+ for (const v of list) {
483
649
  try {
484
- rmSync(v.dir, { recursive: true, force: true });
650
+ // Windows 上刚退出的 DSH 子进程可能还没松手:给几次重试。
651
+ rmSync(v.dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
485
652
  } catch (e) {
486
653
  v.error = e.message;
654
+ continue; // 会话目录没删掉就别动它的其它产物,保持一致
655
+ }
656
+ removedIds.push(v.id);
657
+ touchedBuckets.add(v.bucketPath);
658
+ // 投影缓存:删目录不会带走它(见 PROJCACHE_DIR 注释)。
659
+ try {
660
+ rmSync(join(PROJCACHE_DIR, `${v.id}.json`), { force: true });
661
+ } catch (e) {
662
+ v.projcache_error = e.message;
487
663
  }
488
664
  // 会话文件都没了,墓碑也就没有意义:一起清掉,避免测试残留。
489
665
  try {
@@ -492,21 +668,81 @@ export function purgeTestSessions({ dryRun = false, ids = [], testOnly = false,
492
668
  /* 墓碑不存在或删不掉都不影响会话清理 */
493
669
  }
494
670
  }
495
- // 清掉因删空而残留的空分桶目录(每个工作区一个桶,空桶会一直堆着)
496
- if (existsSync(SESSIONS_ROOT)) {
497
- for (const bucket of readdirSync(SESSIONS_ROOT)) {
498
- const p = join(SESSIONS_ROOT, bucket);
499
- try {
500
- if (readdirSync(p).length === 0) rmSync(p, { recursive: true, force: true });
501
- } catch {
502
- /* 桶被占用或权限问题就跳过 */
671
+ // 只清**我们刚删空**的分桶(每个工作区一个桶);别的空桶不是我们造的,不碰。
672
+ for (const bp of touchedBuckets) {
673
+ try {
674
+ if (readdirSync(bp).length === 0) rmSync(bp, { recursive: true, force: true });
675
+ } catch {
676
+ /* 桶被占用或权限问题就跳过 */
677
+ }
678
+ }
679
+ // 工作区注册:定点摘除(不再整盘回填,见 unregisterSessions 注释)。
680
+ const reg = unregisterSessions(removedIds);
681
+ return { count: list.length, victims: list, skipped, registry: reg };
682
+ }
683
+ return { count: list.length, victims: list, skipped, registry: null };
684
+ }
685
+
686
+ /**
687
+ * 清理投影缓存里的**孤儿**:`<id>.json` 还在,但磁盘上已经没有这个会话了。
688
+ *
689
+ * 只删 cwd(缓存里 `record.identity.cwd`)是测试目录的孤儿;认不出 cwd 或不是测试目录的
690
+ * 一律保留,只计数。`minAgeMs` 内刚写过的也不动(避免和正在创建会话的进程抢跑)。
691
+ * 会话目录不可读时 fail-closed(什么都不删)。
692
+ */
693
+ export function purgeOrphanProjcache({ dryRun = false, minAgeMs = 60_000 } = {}) {
694
+ const res = { count: 0, bytes: 0, victims: [], kept_non_test: 0, error: null };
695
+ if (!existsSync(PROJCACHE_DIR)) return res;
696
+ const onDisk = new Set();
697
+ try {
698
+ for (const bucket of readdirSync(SESSIONS_ROOT)) {
699
+ try {
700
+ for (const d of readdirSync(join(SESSIONS_ROOT, bucket), { withFileTypes: true })) {
701
+ if (d.isDirectory()) onDisk.add(d.name);
503
702
  }
703
+ } catch {
704
+ // 单个分桶读不了:保守起见当作"可能含有任何会话"——本次不清孤儿
705
+ return { ...res, error: `分桶不可读: ${bucket}` };
706
+ }
707
+ }
708
+ } catch (e) {
709
+ return { ...res, error: `会话目录不可读: ${e.message}` };
710
+ }
711
+ const now = Date.now();
712
+ for (const f of readdirSync(PROJCACHE_DIR)) {
713
+ if (!f.endsWith('.json')) continue;
714
+ const id = f.slice(0, -5);
715
+ if (!SAFE_SESSION_ID.test(id) || onDisk.has(id)) continue;
716
+ const file = join(PROJCACHE_DIR, f);
717
+ let st;
718
+ try {
719
+ st = statSync(file);
720
+ } catch {
721
+ continue;
722
+ }
723
+ if (!st.isFile() || now - st.mtimeMs < minAgeMs) continue;
724
+ let cwd = '';
725
+ try {
726
+ cwd = JSON.parse(readFileSync(file, 'utf8'))?.record?.identity?.cwd ?? '';
727
+ } catch {
728
+ /* 解析不了:认不出 cwd,保留 */
729
+ }
730
+ if (!isTestSessionCwd(cwd)) {
731
+ res.kept_non_test++;
732
+ continue;
733
+ }
734
+ res.victims.push({ id, file, cwd, bytes: st.size });
735
+ res.count++;
736
+ res.bytes += st.size;
737
+ if (!dryRun) {
738
+ try {
739
+ rmSync(file, { force: true });
740
+ } catch {
741
+ /* 删不掉就留着,下次再来 */
504
742
  }
505
743
  }
506
- // 删完之后把注册表里对应的 id 也摘掉
507
- backfillWorkspaces({ includeTemp: true });
508
744
  }
509
- return { count: victims.length, victims };
745
+ return res;
510
746
  }
511
747
 
512
748
  /** 清掉"没有会话且路径已不存在"的空工作区条目。 */
@@ -553,7 +789,7 @@ export function pruneTestWorkspaces({ dryRun = false } = {}) {
553
789
  if (!p) continue;
554
790
  // ★ 只删测试自己造的临时目录(dsh-any-agent-*)与桌面测试目录。
555
791
  // 以前用 isTempPath(p) 会把用户在 %TEMP% 里的真实项目登记也删掉。
556
- if (!isTestTempPath(p) && !isTestDesktopPath(p)) continue;
792
+ if (!isTestTempPath(p) && !isTestDesktopPath(p) && !isTestScratchPath(p)) continue;
557
793
  removed.push({ id, path: p, sessions: ws?.sessionIds?.length ?? 0 });
558
794
  delete doc.tables.workspaces[id];
559
795
  doc.global.workspaceIds = (doc.global.workspaceIds ?? []).filter((x) => x !== id);
@@ -596,4 +832,4 @@ export function listWorkspaces() {
596
832
  }));
597
833
  }
598
834
 
599
- export { WORKSPACE_FILE, SESSIONS_ROOT };
835
+ export { WORKSPACE_FILE, SESSIONS_ROOT, PROJCACHE_DIR };