dsh-agent-board 1.7.8 → 1.8.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/README.md +19 -8
- package/index.mjs +1 -0
- package/lib/cleanup.mjs +256 -0
- package/lib/client/dashboard.js +139 -11
- package/lib/client/kernel.js +32 -7
- package/lib/client/task-detail.js +13 -7
- package/lib/client.js +184 -25
- package/lib/core.mjs +24 -4
- package/lib/dispatch.mjs +187 -5
- package/lib/rpc.mjs +173 -26
- package/lib/usage.mjs +13 -4
- package/package.json +1 -1
- package/test/cleanup.test.mjs +341 -0
- package/test/conditional-get-tasks.test.mjs +96 -0
- package/test/core.test.mjs +410 -7
- package/test/helpers/mock-ctx.mjs +7 -2
- package/test/integration.test.mjs +153 -0
- package/test/scoreboard-client.test.mjs +15 -9
- package/test/scoreboard.test.mjs +44 -4
- package/test/ui-zoom-client.test.mjs +76 -0
package/README.md
CHANGED
|
@@ -113,13 +113,13 @@ dsh plugin --profile web remove dsh-agent-board
|
|
|
113
113
|
|
|
114
114
|
## 功能总览
|
|
115
115
|
|
|
116
|
-
**核心能力一页速览**(`★` 为 v1.7.5–1.
|
|
116
|
+
**核心能力一页速览**(`★` 为 v1.7.5–1.8.0 新栈):
|
|
117
117
|
|
|
118
118
|
- **看板**:六列状态流、拖拽 / 多选批量 / 文本·优先级·标签筛选、归档留档、会话隔离
|
|
119
|
-
- **派发**:一次性 Worker / 独立 Verifier、依赖调度(DFS 环检测)、touches 文件级排他、可续跑 Worker、Team
|
|
119
|
+
- **派发**:一次性 Worker / 独立 Verifier、依赖调度(DFS 环检测)、touches 文件级排他、可续跑 Worker、Team 托管、**spawn 落闸锁内重验**(spawnGateOk:claim 占位与落闸决策读同一把锁的同一份快照,terminate 落在 spawn 在途窗口即弃权——同卡同秒双 run 竞态根治)、**高优干预分态**(intervene 无活跃 run 时区分「续跑窗口,干预随续跑注入」与「真无 run」,返回体带 state 字段)
|
|
120
120
|
- **验收治理** `★`:硬性验收脚本独立复跑、驳回全量带回重派、Verifier 自测指南(userTest)、风险队列「待你过目」、验收时序防护(Verifier 在场时主窗口抢批需显式 `force:true`;有活跃 run 的卡禁止归档;Verifier 迟到结论落 `lateVerdict` 留痕+迟到驳回直投告警,不再静默吞掉)
|
|
121
121
|
- **度量** `★`:Token 消耗(总量 / 有效 / 缓存读,按模型·按任务·按日)+ 记分卡(模型表现 / 质量趋势 / 驳回聚类)
|
|
122
|
-
- **可运维性** `★`:运行时健康自检(派发冻结 / 结算停滞告警)、异常驱动审视(风险队列 / 质量异动告警 / 批次摘要)、起草 lint、数据安全(原子写盘 / 腐坏隔离 / 自动 salvage
|
|
122
|
+
- **可运维性** `★`:运行时健康自检(派发冻结 / 结算停滞告警)、异常驱动审视(风险队列 / 质量异动告警 / 批次摘要)、起草 lint、数据安全(原子写盘 / 腐坏隔离 / 自动 salvage)、**僵尸 run 留档清扫**(host 重启后「落定卡上的 running 无 endedAt 留档」在派发周期自动关账 `stale-closed` + 清认领位,归档门禁不再被幻影 run 永久拦截)
|
|
123
123
|
- **学习反馈**:候选教训信号 → 主窗口沉淀(零耦合,不碰任何笔记 / 记忆工具 API)
|
|
124
124
|
|
|
125
125
|
以下各节是完整规格。
|
|
@@ -135,7 +135,10 @@ dsh plugin --profile web remove dsh-agent-board
|
|
|
135
135
|
- 拖拽流转、多选批量操作(带一步撤销)、文本/优先级/标签筛选;卡片键盘可达(Tab 聚焦 + Enter/Space 开详情),详情页附「流转到」按钮组(状态迁移的键盘替代通道,失败内联回显)
|
|
136
136
|
- 归档区:时间倒序 + 排序选择器 + 纵向滚动
|
|
137
137
|
- Esc 逐级关闭(详情 → 看板 → 面板)
|
|
138
|
+
- **详情面板操作消歧**:破坏性操作(取消任务 / 删除)独立成「危险操作」区(红色虚线分隔 + a11y 分组 + confirm 预警),与表单按钮(保存/发布)视觉分层;头部右上 ✕ 显性关闭钮——「取消」不再被误读为「关面板」
|
|
139
|
+
- **fork/迁移会话同样有看板**:根判定用会话头 `delegationDepth`(fork/seed 会话 depth=0 认根;真子代理 depth≥1 才隐藏看板入口),不再误把带 parentSession 的 fork 当子代理
|
|
138
140
|
- 全部结构性图标为 Lucide 线性 SVG(`currentColor` 跟随主题,浅深色自适应)
|
|
141
|
+
- **界面缩放档位**(⚙️ 设置弹层「界面」小节,90% / 100%(默认)/ 110% / 125% / 150% 五档):面板根容器 CSS `zoom` 等比缩放(字体/图标/间距一并放大,只影响本面板、不碰宿主页面),选中即时生效并 `localStorage` 本地记忆(键 `dsh-agent-board-ui-zoom`)
|
|
139
142
|
|
|
140
143
|
### 仪表盘(Token 消耗)
|
|
141
144
|
|
|
@@ -148,7 +151,7 @@ dsh plugin --profile web remove dsh-agent-board
|
|
|
148
151
|
- `get-tasks` 再现算 board 级 `usageSummary`(总量 / 有效合计 / 按模型(合计 `byModel` + 有效分摊 `byModelEff`,两者同源累加)/ Top8(带有效与缓存读拆分)/ 日账双份(范围内 `byDay` + 未过滤全量 `byDayFull`,后者供「今日」「近 7 天」固定口径读取),不落盘额外表;老任务无 `byDay` 时整笔近似归到 `updatedAt` 的本地日,无 `updatedAt` 则不计入任何日)——**只做展示、不做计费断言**,日志读不到或没有 usage 时一律显示「暂无数据」;第二参 `range`(可选 `{ from, to }` 本地日)给定即只聚合该范围内的 run(口径见上条)
|
|
149
152
|
- **主窗口(本会话对话)消耗单列**(`task-muwsol23`):累计行下方新增一行「**主窗口(本会话):今日 X · 累计 Y**」(**总量口径含缓存读**——`task-muxnqunk` 翻转后缓存读不再单列、已含在 total 里,有效值见悬浮 title),按模型分布尾部追加一条「**主窗口(对话)**」(琥珀色与 run 模型区分,总量为 0 不渲染,固定尾部不参与排序);数据源是该看板所属主会话自己的 v4 日志,host 侧**增量尾读**聚合(缓存键=会话 id:文件不变零读、变大只读新增字节段、变小/轮换全量重读一次,尾部半帧不结算下轮补读——3s 轮询稳态零全量重读),五分量与有效口径(e=输入+输出+缓存写)同 Worker 完全一致;**隔离红线:不进 Top 8、不进「本看板累计」、不进架构健康/学习飞轮基数**,只并列展示;随统计范围裁剪(按 `assistant/message` 事件的本地日落点);日志不存在/读取失败/无 usage 一律**静默降级**(不渲染该行,不炸轮询)
|
|
150
153
|
- 统计口径边界:本区累计 / Top 8 / 架构健康与学习飞轮基数只统计**看板派发的 Worker/Verifier run**,**不含主窗口对话**;主窗口对话消耗按上条单列一行 + 模型分布尾部一条并列展示(不越界混入,UI caption 已明示)
|
|
151
|
-
- **「模型表现」区**(记分卡卡3,`task-muxhtgi2`):Token 区下方新增**模型 ×
|
|
154
|
+
- **「模型表现」区**(记分卡卡3,`task-muxhtgi2`):Token 区下方新增**模型 × 场景八指标对比表**——每行一个 模型 × 角色(worker/verifier)× 规模段(小 <1M / 中 1~10M / 大 >10M,按任务有效 token 分桶)桶;八列口径一句话版:**有效均值** = 桶内 run 有效消耗(输入+输出+缓存写,不含缓存读)的均值、**耗时中位** = run 耗时(endedAt−at)中位数(P90 在行 title)、**驳回率** = 归因到本桶的验收驳回 / 桶内 runs、**一次通过率** = 桶内 resolved 任务中零驳回占比、**超时率** = 超时落定 run / 桶内 runs、**失败率** = 跑崩落定(incomplete/error/failed)run / 桶内 runs(terminated 人工终止不计入,`task-mv73ym6i`)、**续跑率** = resume 续跑记录 / 桶内 runs、**缓存命中率** = cacheRead/(input+cacheRead)(⚠️ 跨 provider 对比先核 input 是否含缓存读);表头列名均带 title 口径说明;表头下两个**本地筛选器**(角色 全部/worker/verifier + 规模段 全部/小/中/大)只在已返回的 scoreboard 上过滤、**不重拉**;桶内 runs<5 整行**灰显** + title「样本 <5,仅供参考」(不参与任何「最优」强调);无数据显「暂无足够 run 数据」;Token 区 caption 新增「**→ 模型表现**」锚链接一键滚动到位。数据源 = `get-tasks` 响应里的 `usageSummary.scoreboard`(卡2 host 聚合底座 `buildScoreboard`,与 Token 区同一统计范围口径裁剪)
|
|
152
155
|
- **「质量趋势」区**(记分卡卡4,`task-muxhtgil`):模型表现区下方新增全局趋势四块(P1 质量 KPI 并入)——**一次通过率**(近 14 天柱形:当日落定 resolved 任务中零驳回占比,今日柱高亮 + 块头标今日值,无落定日画灰基线不假装 0%)、**卡时长分布**(四桶横条 <10m / 10-30m / 30-60m / >60m,创建→落定墙钟含排队/验收全程)、**超时率走势**(近 14 天迷你柱行:当日超时落定 run / 当日 runs)、**续跑成功率**(resume 续跑记录中 completed 占比,范围总量口径、host 出参无逐日故不伪造 byDay);全部读 `usageSummary.scoreboard.trends`(卡2 已按统计范围裁剪,client 只渲染不重拉),范围激活时区头带「范围内: …」徽章;近 14 天为固定窗口(范围落在窗口外时对应日柱为空,caption 写明);整区/单块空态均灰显不炸
|
|
153
156
|
- **「驳回聚类」区**(记分卡卡5,`task-muxhu1zv`):质量趋势区下方新增**驳回原因 Top3**——host 对验收驳回文本做规则法关键词聚类(不上 embedding:驳回文本结构模板化;首命中归类,占比分母=驳回总数,未匹配进「其他」兜底桶;按驳回消息自身本地日落点随统计范围裁剪),每行 TopN 类目 + 占比 + 代表原文(行 title 悬浮,各截 80 字)+ **「建议沉淀为约定」结构化文本**(形如「驳回 Top1『验收脚本未实跑』占 42%——建议在派发约定/Worker prompt 补一条:…」,一键复制按钮,沉淀动作人工);挂 `usageSummary.scoreboard.rejectionClusters`,老 host 缺字段整块不渲染,无驳回显空态。同一通道的**「按表现荐模型」hint**(卡5③):某模型在中小卡 worker 桶合并后 runs≥10 且一次通过率≥90% 且有效均值较其他每个合格模型严格低 >30% → 「架构健康」区亮黄条「模型 X 在中小卡表现最优(通过率 92% · 有效均值 0.8M,较其他合格模型低 35%),建议设为默认 workerModel」;无足够数据/无对比对象不亮
|
|
154
157
|
- 「架构健康」区(架构自省 L1):`get-tasks` 顺带对**近 50 张卡**现算四信号(纯函数零存储:touches 声明热度 ≥8 次且占比 ≥40% / 带 touches 任务滞留中位数 >2 倍 / 任务**执行**时长 p90 >45min(claimedAt→resolvedAt 纯干活口径,不含排队)/ 同路径驳回 ≥2 次),命中才在仪表盘渲染提示条(⚠️/ℹ️ 两级,最多 3 条)——让运行数据主动提示"该优化架构了"(如某文件反复成为锁热点=该拆),信号只建议不裁判;**运行时健康自检**(`task-muxhrkbg`)复用同一 hint 通道——host 侧在 poolCycle 成功轮 / settle 结算 / 幽灵回收三处记内存心跳(`state.poolHealth`,不落盘):有可派卡+有空位却 >5min 无成功派发轮 → 亮「🔴 派发循环疑似冻结」,有在跑卡却 >30min 无任何结算 → 黄级「结算通道长时间无活动」,幽灵表项回收 >0 时留一条一次性 info 记录(90s 保鲜、读到即灭)——运行事故不再只靠 e2e 翻车与肉眼发现;**质量异动告警**(异常驱动审视②):`usageSummary.scoreboard.trends` 环比——近 7 天一次通过率较再前 7 天跌 >10 个百分点(两侧样本各 ≥5 防小样本误报)→ 黄条「质量异动:一次通过率本周 X%(上周 Y%),建议抽查近期验收」,超时率 / 驳回率突变各加一条同构规则(阈值常量置顶,恰好 10pp 不亮、10.1pp 亮)
|
|
@@ -157,6 +160,12 @@ dsh plugin --profile web remove dsh-agent-board
|
|
|
157
160
|
- 统计区耗时同口径拆分:「平均排队 / 平均执行」双行展示(平均验收单列不变)
|
|
158
161
|
- 统计区新增「调研 ROI」对比行:resolved/archived 卡按有无调研注入分组现算卡数 / 平均执行时长 / 平均 token(双组总样本 ≥4 才渲染,无调研组明显更慢时数字 warn 色提示)
|
|
159
162
|
|
|
163
|
+
### 存储清理(已结算会话)
|
|
164
|
+
|
|
165
|
+
- **看板内建「已结算会话清理」**(`task-mv2131cz`,用户指令「这个清理可以做到看板上」):DSH 宿主无会话删除 API(归档=执行门禁不删数据),看板是唯一知道「哪些会话账已结清」的组件——清理只能长在看板上。仪表盘新增「**存储清理**」区:「预览可清理会话」按钮 → 展示可删会话数 + 预估释放空间 + 按卡分组明细 + 跳过项与理由(**只读不删**;体积估算优先读宿主 `SessionPersistenceSnapshot.sizeBytes`,快照缺省时回退目录遍历);「清理」按钮**二次确认**后真删(删除不可恢复,token 账早已记入卡片、删除不影响任何统计),完成后报告「已释放 N / 跳过 M 及理由」
|
|
166
|
+
- **可删判定:卡龄主闸 + 三底闸**(host `lib/cleanup.mjs` 纯函数):只删**本板 runs[] 关联的会话 id**,且 **主闸 = 卡龄**——所属卡 `status=archived` 且 `archivedAt` 距今 ≥ `cleanupRetentionDays`(板级设置项,默认 **7 天**、可配 1-90,回收与归档生命周期对齐);**三底闸永留**——① `outcome` 已落定(非 `running`)② 会话当前不活跃(不在 `ctx.agents` 活跃树 / 非本会话血统)③ 路径安全(id 白名单 + `rm` 前 `isWithin` 再验)——任一解析不确定即跳过并计入理由(宁漏勿错删)。`continuable`/`usageRecorded` 两道旧闸已撤:归档满保留天数即视为账本封存、续跑意愿过期(恢复待办时旧会话已被删走,既有「冷复活失败→回落新 spawn」路径承接,零新逻辑)
|
|
167
|
+
- **删除范围**:会话日志目录 `~/.dsh/sessions/<bucket>/<id>/`(`fs.rm` recursive)+ projcache 缓存 `~/.dsh/storages/session_projcache/sessions/<id>.json`;**路径逃逸双闸防护**:① 会话 id 白名单 `[A-Za-z0-9_-]`(含 `../`、`/`、`\` 一律拒)② `rm` 前再验目标绝对路径仍在 sessions/projcache 桶内(`path.relative` 判定)。**注册表索引残留**:`~/.dsh/storages/session_projcache.json`(宿主托管的 `version/seq` 结构)**不清理**——只是元数据索引,残留条目不占会话日志体积,宿主自会 GC;每次清理写 `board.cleanupLog` 留痕(时间/操作者/删除数/释放字节/会话 id 清单,上限 50 条)
|
|
168
|
+
|
|
160
169
|
### 学习反馈(候选教训信号 → 主窗口沉淀)
|
|
161
170
|
|
|
162
171
|
- **信号源架构(零耦合)**:看板只产「候选教训**信号**」,不做**存储**——不调用任何笔记/记忆工具的 API、不写任何外部文件、也不知道教训最终被存到哪;用不用、存进哪个工具(如 `note_search` / `note_manage`)完全由主窗口 agent 自己决定
|
|
@@ -178,21 +187,23 @@ draft → pending → in-progress → verifying → resolved → archived
|
|
|
178
187
|
- **依赖调度**:`dependsOn` 声明依赖(DFS 环检测),依赖全部完成后才会被派发,串行链路自动编排
|
|
179
188
|
- **管线分档**:`full`(执行+验证)/ `work`(只做不验)/ `direct`(不进池,主窗口直接处理),创建时按规则自动分类、可手动覆盖
|
|
180
189
|
- **硬性验收**:`acceptance` 字段写验收脚本命令,Worker 必须实际运行、Verifier 必须独立复跑
|
|
181
|
-
- **git 纪律红线**:Worker prompt 写死禁止还原命令(`git checkout` / `git restore` / `git reset --hard` / `git clean` 等——会冲掉并行 Worker 与你自己的未提交在途编辑);只改自己写的文件、别人改了别的文件与你无关,确实需要干净基线时走歧义上报(`board_report` kind=escalate
|
|
190
|
+
- **git 纪律红线**:Worker prompt 写死禁止还原命令(`git checkout` / `git restore` / `git reset --hard` / `git clean` 等——会冲掉并行 Worker 与你自己的未提交在途编辑);只改自己写的文件、别人改了别的文件与你无关,确实需要干净基线时走歧义上报(`board_report` kind=escalate)由主窗口裁决;**提交只允许精确路径 `git add`**(本卡 `touches` 声明的文件,或逐一列出明确改过的文件路径),禁止 `git add -A` / `git add .` / `git add 目录`——并行 Worker 共享暂存区,宽泛 add 会把别卡在途编辑卷进 commit(实证 n-mv6xntg1p6i5:三起暂存吞并)
|
|
191
|
+
- **大文件交付纪律**:Worker prompt 写死分块写入指引——单文件交付预估 >8KB(或 spec 含全量文案/长代码)时禁止单次 `write` 写入全量(单次超长输出会撞模型输出上限、上下文耗尽零产出),先 `write` 落主体骨架、再用 `edit` 追加续块(每块 ≤4KB)逐块落地
|
|
182
192
|
- **文件级排他**:`touches` 声明本任务要改的文件/glob(如 `["src/**", "README.md"]`);持有文件锁的任务(`in-progress`/`verifying`)与候选 touches 重叠就跳过本轮(卡片显示 `🔒 等文件释放`,详情页列出在等谁),**锁随工作态**:状态流转到已完成(`resolved`)即放锁,`cancelled`/归档同样不再持锁(归档回归纯收纳动作、不再是释放点)——锁只护「正在写」的阶段,「验收后-提交前」的窗口期由主窗口「回执到即提交」纪律 + 史诗 post-hook 承接,不用长持锁把整批串行化。避免并行 Worker 改同一批文件互踩;手动「派发」遇到冲突会列出冲突任务,确认后才以 `force` 越权派发
|
|
183
193
|
- **里程碑进展通道**:Worker 每完成一个可验证的里程碑,可调用 `board_report`(`kind: "progress"`,`question` 写一行进展摘要 ≤200 字符)上报——进行中的卡片显示「📈 最近进展 · 相对时间」(覆盖式只留最新一条),详情页消息流保留全部 progress 条目
|
|
184
194
|
- **防表演式汇报**:进展契约只写在 Worker prompt 里、且要求「有实际产物/结论才报」(禁止定时汇报);progress **静默不通知主窗口**(不进回执聚合),也不写 `history` 流转记录,避免刷屏
|
|
185
195
|
- **子任务**:父子层级 + 上下文继承 + 父任务自动流转 + 级联归档(僵尸态出清:`archive-task` 对「无活跃 run 的 in-progress」——典型如被 parentKick 推进后子任务已全部归档的史诗——直接放行,有活跃 run 的仍拒);**归档子任务仍计入史诗进度并在详情留档可见**——进度分子口径 `settled = resolved | cancelled | archived`、分母也含归档,归档一张子卡不会再让史诗进度从 `0/10` 退化成 `0/9`(进度只增不减),全归档的父卡也照常显示徽章;详情子任务清单不排归档行(灰化 + 行尾「已归档」徽章 + 沉底排序,点击仍可进子卡看留档)
|
|
186
196
|
- **死会话零打扰**:15s 派发心跳对无活 root 的会话板直接跳过(不读盘/不写盘/零日志,会话重开后自动恢复派发);`create-task` 拒绝不完整 sessionId(防裸短 id 建出幻影板)
|
|
187
|
-
-
|
|
197
|
+
- **面板轮询条件请求 + 渲染短路**:`get-tasks` 响应附 `tasksHash`;客户端轮询带上轮 hash 作 `ifHash`,host 比对一致时返回 `{unchanged:true}` 轻量响应(**不含 tasks 全量**——轮询载荷从 1.34MB 级降到 KB 级,usage/healthHints/池状态等轻量聚合照常返回),前端保留 `state.tasks` 不动并跳过重渲染;hash 不一致 / 不带 `ifHash` / `includeArchived` 一律全量返回(老客户端零回归)
|
|
188
198
|
- **看板抽屉稳定性(isRoot 持久语义 + 蝶变防抖)**:`isRoot` 判定改读**持久 parentSession**(根会话=无父)——休眠根会话(有板有卡但无活 agent)也算 root,入口不再分钟级消失;子代理会话(parentSession 指向父)恒不显示入口。客户端防抖仍保留兜底:已确认 root 的会话需**连续 3 次(≈9s)false** 才收起抽屉(会话生命周期内「曾确认 true」粘滞),看板不再在生成状态切换时突然消失;从未确认 root 的子代理会话仍即时隐藏入口,保护语义不变
|
|
189
199
|
- 卡片识别层:父卡显示「📦 史诗 · settled/total」徽章 + 3px 迷你进度条(`settled = resolved | cancelled | archived`,total 同口径含归档;有在跑子任务时附「▸ 在跑:标题」行);子卡标题下显示「↳ 父任务标题」;依赖未满足的待办卡底部灰字「⛓ 等待「依赖标题」」(childStats 由 host 现算,缺字段一律不渲染)
|
|
190
200
|
- 详情父子区块:父卡详情列子任务清单(状态色点 + 标题,**含已归档留档**——归档行灰化 + 行尾「已归档」徽章并沉底,点击直达子卡详情,标题行汇总 settled/total);子卡详情顶行「↳ 史诗:父标题」点击回跳父卡
|
|
191
201
|
- **可选 hooks(史诗可选点位:前置/后置各是一次真实 agent 运行)**:`epic.hooks = { pre: { enabled, prompt, state, runId }, post: 同构 }`——**前置**让子任务具备开跑条件(串行闸门:未完成前该史诗的子任务一张都不派),**后置**把这批活收口(全部子任务了结后跑一遍,完成后史诗才转验证中);prompt 只给薄框架契约,具体动作由 hook agent **自行决策**,**吃不准就走歧义上报**(失败即转阻塞等人裁决:重试/跳过/放弃,不自动重跑)。hook run 记在 `epic.runs[]`(role `hook-pre`/`hook-post`),卡片带相位徽章(`⏳ 前置准备中` / `🧪 收尾中` / `⚠️ hook 失败待裁决`,无 hooks 的老卡零渲染),详情页 hooks 区可编辑 enabled+prompt、查看 state/耗时并跳转 run 会话、失败一键跳裁决区;**hooks 仅主窗口可设**(工具与 RPC 双通道门禁),commit/push 不入任何默认形态
|
|
192
202
|
- **删除通道(真删,无 undo)**:`delete-task` RPC(卡片 hover 垃圾桶按钮 / 详情页「删除」按钮,均先 `confirm('删除不可恢复,确认删除「标题」?')`)+ `batch-op op='delete'`(多选模式底部「批量删除」,同样 confirm)。状态门禁:**草稿/待办/阻塞可删**;进行中/验证中拒绝并提示先用 `terminate-agent` 终止(避免在跑的 run 变孤儿);已完成/取消引导改用归档(`archive-task`,留档可检索);有**未归档子任务**时拒删(防 `parentId` 悬空破坏父任务自动流转);已归档任务幂等返回 ok。是真删(从 `tasks` 数组移除),因此**不产生 `batch-undo` 撤销快照**(批量条对 delete 不显示「↩️ 撤销」),删除操作在 host 端 `console.error` 留一行日志便于溯源
|
|
193
|
-
- **任务粒度建议**:单任务 **10~30 分钟**可独立完成为甜区;预计超过 30 分钟的大任务先建一张 **epic 父卡**(`pipeline: direct`,不进池派发),再挂若干 10~30 分钟的子任务(`task_create` 传 `parentId=父卡 id`,有先后顺序用 `dependsOn` 串联),子任务全部完成后父卡自动流转(`checkParentAuto`)——`task_create` 工具描述与 Team
|
|
203
|
+
- **任务粒度建议**:单任务 **10~30 分钟**可独立完成为甜区;预计超过 30 分钟的大任务先建一张 **epic 父卡**(`pipeline: direct`,不进池派发),再挂若干 10~30 分钟的子任务(`task_create` 传 `parentId=父卡 id`,有先后顺序用 `dependsOn` 串联),子任务全部完成后父卡自动流转(`checkParentAuto`)——`task_create` 工具描述与 Team 模式提示词都写了这条契约;**容器卡结构性不派发**:凡有子任务的卡(epic)永不进派发候选(与 `pipeline` 字段无关,不再只靠 `direct` 口头约定——手滑建 `full` 档 epic 也不会被派出占 worker 槽),子任务全了结转 verifying 后派 Verifier 验收不受影响
|
|
194
204
|
- **suggestSplit 软提示**:`task_create` / `create-task` 发现描述超 500 字符、或标题/描述命中「全量 / 整体 / 系统级 / 全面 / 重构 / 所有模块 / 整个」等史诗特征词时,返回体附带一行 `suggestSplit` 建议文案(**只提示,不阻断创建与派发**;未命中则不出现该字段,老调用方无感)
|
|
195
205
|
- **开关 `epicSplit`(史诗拆分总开关)**(⚙️ 入池配置弹层「**功能**」小节,默认**开**):关掉只停**引导**——Team 提示词第 6 条拆分条款整条不注入、`create-task`/`task_create` 响应不再附 `suggestSplit` 软提示;**机制不禁**(显式传 `parentId` 建子卡、史诗自动收口与 hooks 状态机照常工作,明确要拆时不受阻);老看板文件没有该字段 → 读路径自动补 `true`(与升级前行为一致)。工具描述里的粒度契约是静态工具定义(随 request header 快照),不在开关范围
|
|
206
|
+
- **模型字段写保护**:`set-board-config` 对 `workerModel`/`verifierModel` 的非空值校验当前网关可用清单(`list-models` 同款枚举)——手写非法 id(如把熔断通知里的显示名抄成 id)直接拒写并返回合法清单前 8 个,验收 spawn 不再 UNKNOWN_MODEL 秒死;空串=清除放行;宿主无 llm 服务时降级跳过校验不炸。配套熔断通知分态:`UNKNOWN_MODEL` 类失败报「配置非法(不在可用清单)」,与「验收连续失败」区分——mistake 归 mistake,quality 归 quality
|
|
196
207
|
|
|
197
208
|
### 一次性派发(v74 去池化)
|
|
198
209
|
|
|
@@ -316,7 +327,7 @@ dsh web 的 client 运行时不具备模块解析能力(entry 被整体读成
|
|
|
316
327
|
| `kernel.js` | 底座(用户不可感知) | 渲染原语(ic/icText/ActorLink)、RPC 封装与轮询族、共享状态(state/listeners/notify)、图标(ICONS)、共享任务工具,以及面板骨架入口(BoardButton/ViewTab/TopPanel/slots.inject) |
|
|
317
328
|
| `board-list.js` | 看板列表 | 看板列与卡片(Card)、筛选条、多选批量操作条、建卡表单、归档列表 |
|
|
318
329
|
| `task-detail.js` | 任务详情 | 详情抽屉(编辑/流转/冻结/touches/历史会话/高优介入)、歧义裁决对话与候选教训沉淀、依赖区块 |
|
|
319
|
-
| `dashboard.js` | 仪表盘与设置 | 统计图表、Token
|
|
330
|
+
| `dashboard.js` | 仪表盘与设置 | 统计图表、Token 消耗、模型表现(八指标×场景筛选)、质量趋势(通过率/时长分布/超时/续跑)、全局总览、报告生成、团队池视图、池/模型/超时设置与工作模式开关 |
|
|
320
331
|
|
|
321
332
|
四个模块拼进同一个 `apply(ctx)` 函数作用域(`var`/`function` 声明提升使跨模块引用与拼接顺序无关;
|
|
322
333
|
所有同步执行代码——DOM 监听、轮询注册、布局同步、slots.inject——都在 kernel 域内保持原相对顺序)。
|
package/index.mjs
CHANGED
|
@@ -85,6 +85,7 @@ export function apply(ctx) {
|
|
|
85
85
|
pushSysNote: notify.pushSysNote, sessionCwd: session.sessionCwd, // 调研门禁③:epic 发布预检汇总投递 + 预研路径相对解析根
|
|
86
86
|
spawnOneShot: dispatch.spawnOneShot, accumulateRunUsage: dispatch.accumulateRunUsage, readContextPack: dispatch.readContextPack,
|
|
87
87
|
closeRunHistory: dispatch.closeRunHistory, // 手动终止 run 关账(doTerminate 状态无关清理,task-mv1pqy27)
|
|
88
|
+
resumeTarget: dispatch.resumeTarget, // doIntervene 无活跃 rec 时细分续跑窗口/真无 run(task-mv6c7cbo)
|
|
88
89
|
// 上报通道结算入口(task-muwkhqf8):board_report/board_verdict 把任务推进到落定态时,
|
|
89
90
|
// 由 rpc 侧顺手对「该任务当前的 continuable Worker rec」补做收尾三件套
|
|
90
91
|
// (关 run 结局 / usage 落账 / 摘超时臂)——治「工具上报完成后 idle 事件没到 → run 永远 running」。
|
package/lib/cleanup.mjs
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// dsh-agent-board — 已结算会话清理(lib/cleanup.mjs)
|
|
2
|
+
// 纯函数 + node IO,不碰 ctx 与共享状态。看板内建「已结算会话清理」:
|
|
3
|
+
// 扫本板全部任务 runs[],卡龄主闸筛「可删」= 所属卡 status=archived 且 archivedAt 距今 ≥ cleanupRetentionDays
|
|
4
|
+
// (板级设置项,默认 7 天、可配 1-90);三底闸永留 = outcome 已落定(非 running)∧ 会话不活跃
|
|
5
|
+
// (不在活跃树/本会话血统)∧ 路径安全(id 白名单 + rm 前 isWithin 再验)。
|
|
6
|
+
// 路径逃逸防护两道闸:① 会话 id 白名单 [A-Za-z0-9_-];② rm 前再验目标绝对路径仍在 sessions 桶内。
|
|
7
|
+
// 体积估算主源(task-mv39ag1e):官方 SessionPersistenceSnapshot.sizeBytes——sessionPersistence.stat(id)
|
|
8
|
+
// 只读元数据(不读事件日志、不取写所有权,preview 零副作用红线不破),替代逐会话目录递归遍历
|
|
9
|
+
// (Verifier 实测全量预览 ~340ms 同步 IO 阻塞事件循环);stat 缺失/异常/sizeBytes 缺省 → 回退目录遍历(老宿主降级不炸)。
|
|
10
|
+
// 数据源(与 usage.mjs findRunLog 同源布局,2026-10 本机核对):
|
|
11
|
+
// 会话日志目录:~/.dsh/sessions/<bucket>/<id>/(bucket=工作区转义目录名,id=会话 id 目录)
|
|
12
|
+
// projcache 逐会话:~/.dsh/storages/session_projcache/sessions/<id>.json
|
|
13
|
+
// 注册表索引残留:~/.dsh/storages/session_projcache.json(宿主托管、version/seq 结构,**不清理**,
|
|
14
|
+
// 只文档注明残留形态——见 README「存储清理」小节)
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import fsNode from 'node:fs'
|
|
17
|
+
|
|
18
|
+
// ===== 会话 id 安全性(路径逃逸第一道闸)=====
|
|
19
|
+
// 合法会话 id 只含 [A-Za-z0-9_-]:裸 UUID(5c912460-...)、session-<uuid>、import-sess_...、child-... 均符合。
|
|
20
|
+
// 任何出现 . / \ 等路径字符(含 ../ 逃逸、Windows \ 分隔符)的 id 一律拒。
|
|
21
|
+
export function isSafeSessionId(id) {
|
|
22
|
+
if (typeof id !== 'string') return false
|
|
23
|
+
if (!id || id.length > 200) return false
|
|
24
|
+
if (id !== id.trim()) return false
|
|
25
|
+
return /^[A-Za-z0-9_-]+$/.test(id)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 可删判定的跳过原因(稳定机器码,测试与仪表盘按它对齐)
|
|
29
|
+
export var SKIP_REASON_TEXT = {
|
|
30
|
+
'id-invalid': '会话 id 非法(路径逃逸防护)',
|
|
31
|
+
'not-settled': 'outcome 未落定',
|
|
32
|
+
'not-archived': '所属卡未归档',
|
|
33
|
+
'too-young': '归档未满保留天数',
|
|
34
|
+
'active': '会话仍活跃/属本会话血统',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 卡龄主闸保留天数(板级设置项 cleanupRetentionDays 与此对齐:默认 7 天、可配 1-90)
|
|
38
|
+
export var CLEANUP_RETENTION_DEFAULT = 7
|
|
39
|
+
export var CLEANUP_RETENTION_MIN = 1
|
|
40
|
+
export var CLEANUP_RETENTION_MAX = 90
|
|
41
|
+
var MS_PER_DAY = 86400000
|
|
42
|
+
|
|
43
|
+
function retentionOf(opts) {
|
|
44
|
+
var d = (opts && typeof opts.retentionDays === 'number' && opts.retentionDays >= CLEANUP_RETENTION_MIN) ? Math.floor(opts.retentionDays) : CLEANUP_RETENTION_DEFAULT
|
|
45
|
+
if (d > CLEANUP_RETENTION_MAX) d = CLEANUP_RETENTION_MAX
|
|
46
|
+
return d
|
|
47
|
+
}
|
|
48
|
+
function nowOf(opts) {
|
|
49
|
+
return (opts && typeof opts.now === 'number' && opts.now > 0) ? opts.now : Date.now()
|
|
50
|
+
}
|
|
51
|
+
// 归档是否已满保留天数:archivedAt 缺失/非法一律不满足(宁漏勿错删,绝不因时间戳读不出而放行删除)
|
|
52
|
+
export function archivedAged(archivedAt, retentionDays, now) {
|
|
53
|
+
if (!archivedAt) return false
|
|
54
|
+
var at = Date.parse(archivedAt)
|
|
55
|
+
if (!(at > 0)) return false
|
|
56
|
+
return (now - at) >= retentionDays * MS_PER_DAY
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 单个 run 条目可删判定:返回 { deletable:true } 或 { deletable:false, reason }。
|
|
60
|
+
// 入参从「run 级」升到「run + 所属卡」级——卡龄主闸需要卡的 status/archivedAt。
|
|
61
|
+
// 闸序(先硬后软):① id 合法 ② outcome 落定(非 running)③ 卡龄主闸(archived + 满保留天数)④ 不活跃。
|
|
62
|
+
export function classifyRun(run, card, blockedIds, opts) {
|
|
63
|
+
var id = (run && typeof run.id === 'string') ? run.id : ''
|
|
64
|
+
if (!isSafeSessionId(id)) return { deletable: false, reason: 'id-invalid' }
|
|
65
|
+
if (!run.outcome || run.outcome === 'running') return { deletable: false, reason: 'not-settled' }
|
|
66
|
+
// 卡龄主闸(回收与归档生命周期对齐):run 可删 ⟺ 所属卡已归档且归档满 retentionDays。
|
|
67
|
+
if (!card || card.status !== 'archived') return { deletable: false, reason: 'not-archived' }
|
|
68
|
+
if (!archivedAged(card.archivedAt, retentionOf(opts), nowOf(opts))) return { deletable: false, reason: 'too-young' }
|
|
69
|
+
if (blockedIds && blockedIds.has(id)) return { deletable: false, reason: 'active' }
|
|
70
|
+
return { deletable: true }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 扫全部任务 runs[]:按会话 id 聚合分类。同一 id 只要任一条不可删 → 整组不可删(宁漏勿错删)。
|
|
74
|
+
// 返回 { items: [{ id, taskId, role }], skipped: [{ id, taskId, reason, text }] }
|
|
75
|
+
export function scanDeletable(tasks, blockedIds, opts) {
|
|
76
|
+
var list = Array.isArray(tasks) ? tasks : []
|
|
77
|
+
var groups = {} // id -> { id, taskId, role, runs: [{ run, card }] }
|
|
78
|
+
var order = []
|
|
79
|
+
for (var ti = 0; ti < list.length; ti++) {
|
|
80
|
+
var t = list[ti]
|
|
81
|
+
if (!t || !Array.isArray(t.runs)) continue
|
|
82
|
+
for (var ri = 0; ri < t.runs.length; ri++) {
|
|
83
|
+
var r = t.runs[ri]
|
|
84
|
+
if (!r || typeof r.id !== 'string' || !r.id) continue
|
|
85
|
+
var g = groups[r.id]
|
|
86
|
+
if (!g) { g = groups[r.id] = { id: r.id, taskId: t.id, role: r.role || '', runs: [] }; order.push(r.id) }
|
|
87
|
+
g.runs.push({ run: r, card: t })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
var items = []
|
|
91
|
+
var skipped = []
|
|
92
|
+
for (var oi = 0; oi < order.length; oi++) {
|
|
93
|
+
var id = order[oi]
|
|
94
|
+
var grp = groups[id]
|
|
95
|
+
var reason = null
|
|
96
|
+
for (var k = 0; k < grp.runs.length; k++) {
|
|
97
|
+
var e = grp.runs[k]
|
|
98
|
+
var c = classifyRun(e.run, e.card, blockedIds, opts)
|
|
99
|
+
if (!c.deletable) { reason = c.reason; break }
|
|
100
|
+
}
|
|
101
|
+
if (reason === null) items.push({ id: id, taskId: grp.taskId, role: grp.role })
|
|
102
|
+
else skipped.push({ id: id, taskId: grp.taskId, reason: reason, text: SKIP_REASON_TEXT[reason] || reason })
|
|
103
|
+
}
|
|
104
|
+
return { items: items, skipped: skipped }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ===== 体积估算(只读,绝不写盘)=====
|
|
108
|
+
// 递归目录体积(字节):不存在/读不到返回 0,绝不抛错
|
|
109
|
+
export function dirSizeOf(p) {
|
|
110
|
+
try {
|
|
111
|
+
var st = fsNode.statSync(p)
|
|
112
|
+
if (!st.isDirectory()) return 0
|
|
113
|
+
} catch (_) { return 0 }
|
|
114
|
+
var total = 0
|
|
115
|
+
var stack = [p]
|
|
116
|
+
while (stack.length) {
|
|
117
|
+
var cur = stack.pop()
|
|
118
|
+
var entries
|
|
119
|
+
try { entries = fsNode.readdirSync(cur, { withFileTypes: true }) } catch (_) { continue }
|
|
120
|
+
for (var i = 0; i < entries.length; i++) {
|
|
121
|
+
var e = entries[i]
|
|
122
|
+
var fp = path.join(cur, e.name)
|
|
123
|
+
if (e.isDirectory()) stack.push(fp)
|
|
124
|
+
else { try { total += fsNode.statSync(fp).size } catch (_) {} }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return total
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function fileSizeOf(p) {
|
|
131
|
+
try { return fsNode.statSync(p).size } catch (_) { return 0 }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 定位会话目录:扫 sessionsRoot 下所有桶,返回 <bucket>/<id> 目录路径(存在才返回),否则 null
|
|
135
|
+
export function findSessionDir(id, sessionsRoot) {
|
|
136
|
+
if (!id || !sessionsRoot) return null
|
|
137
|
+
try {
|
|
138
|
+
var buckets = fsNode.readdirSync(sessionsRoot)
|
|
139
|
+
for (var i = 0; i < buckets.length; i++) {
|
|
140
|
+
var cand = path.join(sessionsRoot, buckets[i], id)
|
|
141
|
+
try { if (fsNode.statSync(cand).isDirectory()) return cand } catch (_) {}
|
|
142
|
+
}
|
|
143
|
+
} catch (_) {}
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 一次性扫 sessionsRoot 建 id → 目录路径 索引:3423 会话量级下 preview 只扫一遍桶,
|
|
148
|
+
// 避免「每个可删项都 readdirSync 整棵 sessions 树」的 O(N×M) 重复读盘。
|
|
149
|
+
export function buildSessionDirIndex(sessionsRoot) {
|
|
150
|
+
var idx = {}
|
|
151
|
+
if (!sessionsRoot) return idx
|
|
152
|
+
try {
|
|
153
|
+
var buckets = fsNode.readdirSync(sessionsRoot)
|
|
154
|
+
for (var i = 0; i < buckets.length; i++) {
|
|
155
|
+
var bdir = path.join(sessionsRoot, buckets[i])
|
|
156
|
+
var entries
|
|
157
|
+
try { entries = fsNode.readdirSync(bdir) } catch (_) { continue }
|
|
158
|
+
for (var j = 0; j < entries.length; j++) {
|
|
159
|
+
var fp = path.join(bdir, entries[j])
|
|
160
|
+
try { if (fsNode.statSync(fp).isDirectory()) idx[entries[j]] = fp } catch (_) {}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch (_) {}
|
|
164
|
+
return idx
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 路径仍在根内判定(路径逃逸第二道闸):relative 不以 .. 开头、不是绝对路径、且不等于根本身
|
|
168
|
+
export function isWithin(root, p) {
|
|
169
|
+
if (!root || !p) return false
|
|
170
|
+
var rel = path.relative(root, p)
|
|
171
|
+
if (rel === '') return false // 目标 == 根本身 → 拒绝(绝不删整个桶)
|
|
172
|
+
if (path.isAbsolute(rel)) return false
|
|
173
|
+
return rel !== '..' && rel.indexOf('..' + path.sep) !== 0
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ===== preview:dry-run,只读盘估体积、绝不删任何文件 =====
|
|
177
|
+
// opts.roots = { sessions: '<home>/.dsh/sessions', projcacheDir: '<home>/.dsh/storages/session_projcache/sessions' }
|
|
178
|
+
// opts.statSession(可选,task-mv39ag1e):官方快照面 `sessionPersistence.stat(id)` 的注入形态——
|
|
179
|
+
// 返回 { sizeBytes? } 或 undefined。体积主源走快照,以下四种情况一律回退目录遍历(双轨,老宿主不炸):
|
|
180
|
+
// ① 未注入(老宿主无 sessionPersistence 服务/测试桩)② 返回 undefined(会话不在持久层)
|
|
181
|
+
// ③ snapshot.sizeBytes 缺省(未物化会话/非 JSONL 后端)④ stat 抛异常。
|
|
182
|
+
// 口径注记:sizeBytes=会话日志物理体积(官方快照口径),可能略小于目录全量(目录或含附件等)——预览是估算,可接受。
|
|
183
|
+
async function sessionBytesOf(id, dir, statSession) {
|
|
184
|
+
if (typeof statSession === 'function') {
|
|
185
|
+
try {
|
|
186
|
+
var snap = await statSession(id)
|
|
187
|
+
if (snap && typeof snap.sizeBytes === 'number' && isFinite(snap.sizeBytes) && snap.sizeBytes >= 0) return snap.sizeBytes
|
|
188
|
+
} catch (_) { /* stat 异常 → 回退目录遍历 */ }
|
|
189
|
+
}
|
|
190
|
+
return dir ? dirSizeOf(dir) : 0
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 返回 { total, totalBytes, items: [{id,taskId,role,bytes,sessionDir,projcachePath}], skipped, byTask }
|
|
194
|
+
export async function previewCleanup(tasks, opts) {
|
|
195
|
+
opts = opts || {}
|
|
196
|
+
var roots = opts.roots || {}
|
|
197
|
+
var sessionsRoot = roots.sessions
|
|
198
|
+
var projcacheDir = roots.projcacheDir
|
|
199
|
+
var statSession = opts.statSession
|
|
200
|
+
var list = Array.isArray(tasks) ? tasks : []
|
|
201
|
+
var titleById = {}
|
|
202
|
+
for (var ti = 0; ti < list.length; ti++) { if (list[ti] && list[ti].id) titleById[list[ti].id] = list[ti].title || list[ti].id }
|
|
203
|
+
var scan = scanDeletable(list, opts.blockedIds, opts)
|
|
204
|
+
var dirIndex = buildSessionDirIndex(sessionsRoot)
|
|
205
|
+
var items = []
|
|
206
|
+
var totalBytes = 0
|
|
207
|
+
var byTask = {}
|
|
208
|
+
for (var i = 0; i < scan.items.length; i++) {
|
|
209
|
+
var it = scan.items[i]
|
|
210
|
+
var dir = dirIndex[it.id] || null
|
|
211
|
+
var dirBytes = await sessionBytesOf(it.id, dir, statSession)
|
|
212
|
+
var pcPath = projcacheDir ? path.join(projcacheDir, it.id + '.json') : null
|
|
213
|
+
var pcBytes = pcPath ? fileSizeOf(pcPath) : 0
|
|
214
|
+
var bytes = dirBytes + pcBytes
|
|
215
|
+
totalBytes += bytes
|
|
216
|
+
items.push({ id: it.id, taskId: it.taskId, role: it.role, bytes: bytes, sessionDir: dir, projcachePath: (pcPath && pcBytes > 0) ? pcPath : null })
|
|
217
|
+
var bk = byTask[it.taskId] || (byTask[it.taskId] = { taskId: it.taskId, title: titleById[it.taskId] || it.taskId, count: 0, bytes: 0 })
|
|
218
|
+
bk.count++; bk.bytes += bytes
|
|
219
|
+
}
|
|
220
|
+
return { total: items.length, totalBytes: totalBytes, items: items, skipped: scan.skipped, byTask: Object.keys(byTask).map(function (k) { return byTask[k] }) }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ===== run:真删(confirm 执行)=====
|
|
224
|
+
// 每个目标删前再验 isSafeSessionId + isWithin(路径逃逸第二道闸)。会话目录 rm recursive;
|
|
225
|
+
// projcache json rm force。返回 { deleted, deletedIds, freedBytes, skipped }
|
|
226
|
+
export async function runCleanup(items, opts) {
|
|
227
|
+
opts = opts || {}
|
|
228
|
+
var roots = opts.roots || {}
|
|
229
|
+
var sessionsRoot = roots.sessions
|
|
230
|
+
var projcacheDir = roots.projcacheDir
|
|
231
|
+
var list = Array.isArray(items) ? items : []
|
|
232
|
+
var deletedIds = []
|
|
233
|
+
var freedBytes = 0
|
|
234
|
+
var skipped = []
|
|
235
|
+
for (var i = 0; i < list.length; i++) {
|
|
236
|
+
var it = list[i]
|
|
237
|
+
if (!it || !isSafeSessionId(it.id)) { skipped.push({ id: it && it.id, reason: 'id-invalid' }); continue }
|
|
238
|
+
var dir = it.sessionDir
|
|
239
|
+
var pcPath = it.projcachePath
|
|
240
|
+
// 先验两道路径逃逸(删前整体校验,避免「删了一半才发现另一条逃逸」)
|
|
241
|
+
if (dir && !isWithin(sessionsRoot, dir)) { skipped.push({ id: it.id, reason: 'path-escape' }); continue }
|
|
242
|
+
if (pcPath && projcacheDir && !isWithin(projcacheDir, pcPath)) { skipped.push({ id: it.id, reason: 'path-escape' }); continue }
|
|
243
|
+
var freed = 0
|
|
244
|
+
// 删会话目录(核心目标;失败则整项算跳过,绝不误报成功)。释放字节同走快照主源(sessionBytesOf 内部回退目录遍历)。
|
|
245
|
+
if (dir) {
|
|
246
|
+
try { var db = await sessionBytesOf(it.id, dir, opts.statSession); await fsNode.promises.rm(dir, { recursive: true, force: true }); freed += db } catch (e) { skipped.push({ id: it.id, reason: 'rm-failed' }); continue }
|
|
247
|
+
}
|
|
248
|
+
// 删 projcache json(best-effort:删不掉不阻断主目标)
|
|
249
|
+
if (pcPath && projcacheDir) {
|
|
250
|
+
try { var pb = fileSizeOf(pcPath); await fsNode.promises.rm(pcPath, { force: true }); freed += pb } catch (_) { /* projcache 删不掉不阻断主目标 */ }
|
|
251
|
+
}
|
|
252
|
+
deletedIds.push(it.id)
|
|
253
|
+
freedBytes += freed
|
|
254
|
+
}
|
|
255
|
+
return { deleted: deletedIds.length, deletedIds: deletedIds, freedBytes: freedBytes, skipped: skipped }
|
|
256
|
+
}
|