dsh-agent-board 1.5.0 → 1.5.1
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 +4 -0
- package/index.mjs +188 -14
- package/lib/client.js +69 -6
- package/lib/core.mjs +19 -1
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -80,6 +80,9 @@ dsh plugin --profile web remove dsh-agent-board
|
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
> 看板数据存在 `~/.dsh/tasks-<sessionId>.json`,卸载不删数据。
|
|
83
|
+
>
|
|
84
|
+
> - **归属**:看板文件按**会话**分文件,同时在文件里记 `ownerCwd`(创建该看板的会话工作区路径,取不到则省略该字段)——`~/.dsh/tasks-*.json` 每个文件是一块看板,`list-boards` 全局视图可看到本机所有板。
|
|
85
|
+
> - **重启继承**:DSH 重启后同一会话的根 id 可能漂移,此时新 id 没有对应文件——若同工作区(`ownerCwd` 严格相等)存在**唯一**「原主已不在 `agents.roots()`」的看板,则自动继承:文件重命名为新 id、文件内 `ownerSession` 改写为新 id、`console.error` 留一行 `[task-board] 继承看板 <旧sid> → <新sid>`;**多个候选一律不自动接管**(记一行日志后按空板处理,防误合并,旧板仍可在 `list-boards` 全局视图里看到)。
|
|
83
86
|
|
|
84
87
|
## 功能总览
|
|
85
88
|
|
|
@@ -123,6 +126,7 @@ draft → pending → in-progress → verifying → resolved → archived
|
|
|
123
126
|
- **里程碑进展通道**:Worker 每完成一个可验证的里程碑,可调用 `board_report`(`kind: "progress"`,`question` 写一行进展摘要 ≤200 字符)上报——进行中的卡片显示「📈 最近进展 · 相对时间」(覆盖式只留最新一条),详情页消息流保留全部 progress 条目
|
|
124
127
|
- **防表演式汇报**:进展契约只写在 Worker prompt 里、且要求「有实际产物/结论才报」(禁止定时汇报);progress **静默不通知主窗口**(不进回执聚合),也不写 `history` 流转记录,避免刷屏
|
|
125
128
|
- **子任务**:父子层级 + 上下文继承 + 父任务自动流转 + 级联归档
|
|
129
|
+
- **删除通道(真删,无 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` 留一行日志便于溯源
|
|
126
130
|
- **任务粒度建议**:单任务 **10~30 分钟**可独立完成为甜区;预计超过 30 分钟的大任务先建一张 **epic 父卡**(`pipeline: direct`,不进池派发),再挂若干 10~30 分钟的子任务(`task_create` 传 `parentId=父卡 id`,有先后顺序用 `dependsOn` 串联),子任务全部完成后父卡自动流转(`checkParentAuto`)——`task_create` 工具描述与 Team 模式提示词都写了这条契约
|
|
127
131
|
- **suggestSplit 软提示**:`task_create` / `create-task` 发现描述超 500 字符、或标题/描述命中「全量 / 整体 / 系统级 / 全面 / 重构 / 所有模块 / 整个」等史诗特征词时,返回体附带一行 `suggestSplit` 建议文案(**只提示,不阻断创建与派发**;未命中则不出现该字段,老调用方无感)
|
|
128
132
|
|
package/index.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { zstdDecompressSync } from 'node:zlib'
|
|
|
10
10
|
import os from 'node:os'
|
|
11
11
|
import path from 'node:path'
|
|
12
12
|
import fsNode from 'node:fs'
|
|
13
|
-
const { ah, isb, gsb, gpt, vt, validateDeps, depsSatisfied, depsCancelled, classifyPipeline, seed, normalizeBoard, cfg, claimCheck, claimApply, checkParentAuto, resolveApply, verifyApply, parseSections, parseVerdict, isEscalation, outputText, histNotes, buildContextPackSection, buildWorkerPrompt, buildVerifierPrompt, pickDispatch, isOrphan, PRIO_RANK, patOverlap, overlapsTouches, touchesConflict, holdsFiles, lessonText, pushLesson, LESSON_RECALL_HINT } = core
|
|
13
|
+
const { ah, isb, gsb, gpt, vt, validateDeps, depsSatisfied, depsCancelled, classifyPipeline, seed, normalizeBoard, cfg, claimCheck, claimApply, checkParentAuto, resolveApply, verifyApply, parseSections, parseVerdict, isEscalation, outputText, histNotes, buildContextPackSection, buildWorkerPrompt, buildVerifierPrompt, pickDispatch, isOrphan, PRIO_RANK, patOverlap, overlapsTouches, touchesConflict, holdsFiles, lessonText, pushLesson, LESSON_RECALL_HINT, boardHome } = core
|
|
14
14
|
|
|
15
15
|
function defineTool(options) {
|
|
16
16
|
var userExecute = options.execute
|
|
@@ -230,22 +230,141 @@ export function apply(ctx) {
|
|
|
230
230
|
// 看板文件用「家目录绝对路径 + node:fs」直读写——不再走 fs 服务的相对路径解析
|
|
231
231
|
// (fs 服务的相对路径解析根 = 进程启动 cwd,cwd 一变所有看板静默读成空板;
|
|
232
232
|
// 曾因此导致整个看板状态异常。绝对路径对此永久免疫)
|
|
233
|
-
function boardPath(sid) { return path.join(
|
|
233
|
+
function boardPath(sid) { return path.join(boardHome(), 'tasks-' + sid + '.json') }
|
|
234
234
|
function fileFor(sid) { return boardPath(sid) } // 保留旧名兼容调用点,但已是绝对路径
|
|
235
|
+
|
|
236
|
+
// ===== 看板跨重启继承(会话根 id 漂移 → 不分裂)=====
|
|
237
|
+
// 背景(看板反馈 n-mur1bmrwvoge):DSH 重启后同一会话在宿主侧的根 id 可能变
|
|
238
|
+
// (实测 55b0879a → 7b44fb23),看板按 ownerSession 分文件 → 新卡落新板,
|
|
239
|
+
// 旧板(73 张卡历史)从默认视图消失,用户视角就是「看板被清空了」。
|
|
240
|
+
// 归属改为「工作区」:文件里记 ownerCwd;以新 sid 访问且本 sid 无文件时,
|
|
241
|
+
// 若同工作区存在**唯一**「原主已死(不在 agents.roots())」的看板 → 接管它。
|
|
242
|
+
// 防误继承三闸门:① 只认 ownerCwd 严格相等;② 原主必须不在存活 root 集合里;
|
|
243
|
+
// ③ 候选必须唯一 —— 多候选一律不动(宁可留孤儿板等人工认领,也不合并错板)。
|
|
244
|
+
// 触发点在 rt() 的「文件不存在」分支:工具 / RPC / UI 三通道都走 rt,天然统一受益。
|
|
245
|
+
function activeRootIds() { try { var rs = ctx.agents && ctx.agents.roots(); var s = {}; if (Array.isArray(rs)) for (var i = 0; i < rs.length; i++) s[String(rs[i].id)] = true; return s } catch (_) { return {} } }
|
|
246
|
+
// 找可接管的孤儿看板:返回文件名,或 null(含「多候选」——多候选刻意返回 null)
|
|
247
|
+
function findAdoptableBoardFile(sid, cwd) {
|
|
248
|
+
if (!cwd) return null // 本会话工作区未知 → 绝不猜(猜错就是合并错板)
|
|
249
|
+
var home = boardHome()
|
|
250
|
+
var files
|
|
251
|
+
try { files = fsNode.readdirSync(home) } catch (_) { return null }
|
|
252
|
+
var alive = activeRootIds()
|
|
253
|
+
var hits = []
|
|
254
|
+
for (var i = 0; i < files.length; i++) {
|
|
255
|
+
var f = String(files[i] || '')
|
|
256
|
+
if (f.indexOf('tasks-') !== 0 || f.slice(-5) !== '.json') continue
|
|
257
|
+
var owner = f.slice(6, -5)
|
|
258
|
+
if (!owner || owner === sid) continue // 本 sid 的文件不该走到这里(本函数只在「本板不存在」时调用)
|
|
259
|
+
if (alive[owner]) continue // 原主还活着 → 不是孤儿板,绝不碰
|
|
260
|
+
var d = null
|
|
261
|
+
try { d = JSON.parse(fsNode.readFileSync(path.join(home, f), 'utf8')) } catch (_) { continue }
|
|
262
|
+
if (!vt(d)) continue
|
|
263
|
+
if (d.ownerCwd !== cwd) continue // 老文件没 ownerCwd(undefined)→ 天然不匹配,不会被误接管
|
|
264
|
+
hits.push({ file: f, sid: owner })
|
|
265
|
+
}
|
|
266
|
+
if (hits.length > 1) { // 多候选:记录一行但不自动动(多板并存时人工 list-boards 处理)
|
|
267
|
+
console.error('[task-board] 继承跳过:工作区 ' + cwd + ' 有 ' + hits.length + ' 个候选孤儿板(' + hits.map(function (h) { return h.sid }).join(', ') + '),不自动接管')
|
|
268
|
+
return null
|
|
269
|
+
}
|
|
270
|
+
return hits.length === 1 ? hits[0] : null
|
|
271
|
+
}
|
|
272
|
+
// 瞬时占用白名单(与 wt() 同款):Windows 上 rename/unlink 的目标名被并发读句柄、Defender、
|
|
273
|
+
// 索引器短暂占用时会抛这几个码——退避后重试即可;其它错误(跨设备 EXDEV、只读等)立即放弃。
|
|
274
|
+
var TRANSIENT_FS = { EPERM: 1, EBUSY: 1, ENOTEMPTY: 1, EACCES: 1 }
|
|
275
|
+
function isTransientFs(e) { return !!(e && TRANSIENT_FS[e.code]) }
|
|
276
|
+
// rename 退避重试:成功 true;非瞬时错误或重试耗尽 false(由调用方决定降级/回滚)
|
|
277
|
+
async function renameRetry(from, to, tries) {
|
|
278
|
+
var n = tries || 6
|
|
279
|
+
for (var i = 0; i < n; i++) {
|
|
280
|
+
try { await fsNode.promises.rename(from, to); return true }
|
|
281
|
+
catch (e) {
|
|
282
|
+
if (!isTransientFs(e) || i === n - 1) { console.error('[task-board] rename failed (' + from + ' -> ' + to + '):', String(e)); return false }
|
|
283
|
+
await new Promise(function (r) { setTimeout(r, 60 * (i + 1)) })
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return false
|
|
287
|
+
}
|
|
288
|
+
// unlink 退避重试:ENOENT 视为已达成;重试耗尽返回 false(调用方记一行日志,不再阻断继承)
|
|
289
|
+
async function unlinkRetry(p, tries) {
|
|
290
|
+
var n = tries || 6
|
|
291
|
+
for (var i = 0; i < n; i++) {
|
|
292
|
+
try { await fsNode.promises.unlink(p); return true }
|
|
293
|
+
catch (e) {
|
|
294
|
+
if (e && e.code === 'ENOENT') return true
|
|
295
|
+
if (!isTransientFs(e) || i === n - 1) { console.error('[task-board] unlink failed (' + p + '):', String(e)); return false }
|
|
296
|
+
await new Promise(function (r) { setTimeout(r, 60 * (i + 1)) })
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return false
|
|
300
|
+
}
|
|
301
|
+
// 接管落盘:两条路径,都保证「接管后看板目录里只剩新 sid 那一个文件」(不残留 .adopt-/.tmp 之类残件——
|
|
302
|
+
// 残留一份含全部历史卡的旧板 = 用户磁盘上多一份完整看板,下次继承还会把它算成候选)。
|
|
303
|
+
// ① 主路径 rename(p → np):原子、复用 wt() 同款 EPERM/EBUSY 退避重试。注意 rename 只搬内容,
|
|
304
|
+
// 文件里 ownerSession 仍写着旧 sid,所以必须紧接着用 wt() 把改写后的内容原子写回 np;
|
|
305
|
+
// 写回失败则把文件改名退回 p —— 绝不留下「名字是新 sid、内容写着旧主」的半成品(否则 rt()
|
|
306
|
+
// 读到 ownerSession 不匹配会退回空板,用户视角仍是「看板被清空」)。
|
|
307
|
+
// ② 兜底 rename 走不通(跨设备/顽固占用)→ wt() 写新板 + unlinkRetry 删旧板。先写后删:
|
|
308
|
+
// 任何时刻磁盘上至少有一份完整看板。
|
|
309
|
+
async function adoptBoard(sid, cand, cwd) {
|
|
310
|
+
var p = boardPath(cand.sid), np = boardPath(sid)
|
|
311
|
+
// 目标名已被占用 → 绝不接管(防覆盖:本会话看板若只是「这一刻读不到」,覆盖等于把本板换成孤儿板)
|
|
312
|
+
if (cand.sid === sid || fsNode.existsSync(np)) return false
|
|
313
|
+
var c = null
|
|
314
|
+
try { c = JSON.parse(await fsNode.promises.readFile(p, 'utf8')) } catch (e) { console.error('[task-board] 继承失败(读旧板):', String(e)); return false }
|
|
315
|
+
if (!vt(c)) return false
|
|
316
|
+
c.ownerSession = sid
|
|
317
|
+
if (typeof cwd === 'string' && cwd) c.ownerCwd = cwd
|
|
318
|
+
if (await renameRetry(p, np)) { // ① 主路径:先改名,再把 ownerSession 改写写回
|
|
319
|
+
try {
|
|
320
|
+
await wt(sid, c)
|
|
321
|
+
console.error('[task-board] 继承看板 ' + cand.sid + ' → ' + sid + '(工作区 ' + cwd + ',原主已不在 roots)')
|
|
322
|
+
return true
|
|
323
|
+
} catch (e) {
|
|
324
|
+
console.error('[task-board] 继承失败(改写 ownerSession):', String(e))
|
|
325
|
+
await renameRetry(np, p) // 回滚到旧名:下次 rt 还能再试,且不留半成品
|
|
326
|
+
return false
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
try { await wt(sid, c) } catch (e) { console.error('[task-board] 继承失败(写新板):', String(e)); return false }
|
|
330
|
+
await unlinkRetry(p)
|
|
331
|
+
console.error('[task-board] 继承看板 ' + cand.sid + ' → ' + sid + '(工作区 ' + cwd + ',原主已不在 roots;rename 不通,走写新+删旧)')
|
|
332
|
+
return true
|
|
333
|
+
}
|
|
235
334
|
async function rt(sid) {
|
|
236
|
-
var r
|
|
237
|
-
try { r = await fsNode.promises.readFile(boardPath(sid), 'utf8') } catch (
|
|
335
|
+
var r = null
|
|
336
|
+
try { r = await fsNode.promises.readFile(boardPath(sid), 'utf8') } catch (e) {
|
|
337
|
+
// 只有「文件确实不存在(ENOENT)」才触发继承:EACCES/EPERM/被占 等说明本会话看板很可能存在、
|
|
338
|
+
// 只是这一刻读不到——此时去接管会把别的板写到本会话文件名上(改前行为只是内存空板,不落盘)。
|
|
339
|
+
if (!e || e.code !== 'ENOENT') { console.error('[task-board] 读看板失败(不触发继承): ' + boardPath(sid) + ' :: ' + String(e)); return seedNoPersist(sid) }
|
|
340
|
+
// 本 sid 无看板文件:先试「同工作区唯一孤儿板」继承,接不到才种新板(种板即带 ownerCwd,供下次重启继承)
|
|
341
|
+
try {
|
|
342
|
+
var cwd0 = sessionCwd(sid)
|
|
343
|
+
var cand = findAdoptableBoardFile(sid, cwd0)
|
|
344
|
+
if (cand && await adoptBoard(sid, cand, cwd0)) r = await fsNode.promises.readFile(boardPath(sid), 'utf8')
|
|
345
|
+
} catch (e2) { console.error('[task-board] 继承流程异常(降级为空板):', String(e2)) }
|
|
346
|
+
if (r === null) return seed(sid, sessionCwd(sid))
|
|
347
|
+
}
|
|
238
348
|
try {
|
|
239
349
|
var d = JSON.parse(r)
|
|
240
|
-
if (vt(d) && d.ownerSession === sid) {
|
|
241
|
-
|
|
350
|
+
if (vt(d) && d.ownerSession === sid) {
|
|
351
|
+
teamModeCache[sid] = !!d.teamMode; var nd = normalizeBoard(d); feedbackCache[sid] = nd.feedbackEnabled !== false
|
|
352
|
+
// ownerCwd 回填(只改内存,随下一次写盘落盘):老看板文件没有该字段,而继承判定靠它——
|
|
353
|
+
// 不写盘就永远不能匿名继承。这里不额外做 IO,避免把只读路径变成写路径。
|
|
354
|
+
if (!nd.ownerCwd) { var cw = sessionCwd(sid); if (cw) nd.ownerCwd = cw }
|
|
355
|
+
return nd
|
|
356
|
+
}
|
|
357
|
+
return seed(sid, sessionCwd(sid))
|
|
242
358
|
} catch (_) {
|
|
243
359
|
// JSON 截断/损坏(如强杀打断写盘):隔离留档再种新板——数据不丢,坏文件也不反复 poison
|
|
244
360
|
console.error('[task-board] board file corrupt, quarantining: ' + boardPath(sid))
|
|
245
361
|
fsNode.promises.rename(boardPath(sid), boardPath(sid) + '.corrupt-' + Date.now()).catch(function () {})
|
|
246
|
-
return
|
|
362
|
+
return seedNoPersist(sid)
|
|
247
363
|
}
|
|
248
364
|
}
|
|
365
|
+
// 瞬时读失败/坏文件隔离后的空板必须禁止回写:否则一个「读不到」的瞬间就会把 106 张卡覆成空板。
|
|
366
|
+
// __noPersist 用 non-enumerable 挂载——即使将来有路径漏判把它写出去,JSON.stringify 也会跳过该字段。
|
|
367
|
+
function seedNoPersist(sid) { var d = seed(sid, sessionCwd(sid)); try { Object.defineProperty(d, '__noPersist', { value: true, enumerable: false }) } catch (_) {} return d }
|
|
249
368
|
// 原子写盘:先写临时文件再 rename——强杀若发生在写盘中途,磁盘上最多留个 .tmp 残件,
|
|
250
369
|
// 看板本体永远不会是截断的半个 JSON(此前非原子直写,kill 中写 = 看板被 seed 清空)
|
|
251
370
|
// Windows 特有问题:rename 目标被并发读句柄/Defender/索引器短暂占用时抛 EPERM/EBUSY
|
|
@@ -275,7 +394,7 @@ export function apply(ctx) {
|
|
|
275
394
|
// 写成功后异步触发一次 poolCycle(派发/回收反应快),按会话去抖避免连环触发
|
|
276
395
|
var cyclePending = {}
|
|
277
396
|
function kickCycle(sid) { if (cyclePending[sid]) return; cyclePending[sid] = true; var tm = ctx.timer; var go = function () { cyclePending[sid] = false; poolCycle(sid).catch(function () {}) }; if (tm) tm.timeout(50).then(go); else Promise.resolve().then(go) }
|
|
278
|
-
function mutateLocked(sid, mutate, skipKick) { return withLock(sid, async function () { var d = await rt(sid); var r = await mutate(d); if (r !== null && r !== undefined) { await wt(sid, d); if (!skipKick) kickCycle(sid); return r } return r }) }
|
|
397
|
+
function mutateLocked(sid, mutate, skipKick) { return withLock(sid, async function () { var d = await rt(sid); if (d && d.__noPersist) { console.error('[task-board] 看板暂时不可读,拒绝在空板上覆写(防瞬时读失败清板): ' + sid); return { ok: false, error: '看板暂时不可读,请重试' } } var r = await mutate(d); if (r !== null && r !== undefined) { await wt(sid, d); if (!skipKick) kickCycle(sid); return r } return r }) }
|
|
279
398
|
function jo() { return { schema: { type: 'object', additionalProperties: true }, render: function (a, v) { return [{ type: 'text', text: JSON.stringify(v, null, 2) }] } } }
|
|
280
399
|
// touches(文件级排他声明)归一化:非字符串项过滤掉,空数组 = 不声明(无锁语义)。
|
|
281
400
|
// 上限 20 条,与 contextFiles 上限一致,防止 prompt/看板文件被超长清单撑爆。
|
|
@@ -918,7 +1037,7 @@ export function apply(ctx) {
|
|
|
918
1037
|
})
|
|
919
1038
|
// 全局多会话总览:聚合本机所有看板的任务计数(只读,供 dashboard 跨会话视图)
|
|
920
1039
|
handle('list-boards', async function (args) {
|
|
921
|
-
var home =
|
|
1040
|
+
var home = boardHome()
|
|
922
1041
|
var out = []
|
|
923
1042
|
var files
|
|
924
1043
|
try { files = fsNode.readdirSync(home).filter(function (f) { return f.indexOf('tasks-') === 0 && f.slice(-5) === '.json' }) } catch (e) { return { ok: true, boards: [] } }
|
|
@@ -945,6 +1064,42 @@ export function apply(ctx) {
|
|
|
945
1064
|
handle('resolve-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'in-progress') return { ok: false, error: 'not in-progress' }; return resolveApply(d, t, actor, args.status, args.resolution, args.resolution || args.status) }) })
|
|
946
1065
|
handle('verify-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'verifying') return { ok: false, error: 'not verifying' }; delete t.escalation; delete t.verifyRetries; var r = verifyApply(d, t, actor, args.verdict, args.comment); if (args.verdict === 'rejected') pushRejectLesson(d, t, args.comment); return r }) })
|
|
947
1066
|
handle('archive-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var a = d.tasks; var t = a.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (t.status !== 'resolved' && t.status !== 'cancelled') return { ok: false, error: 'cannot archive' }; var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'manual archive'); var ca = 0; gsb(t.id, a).forEach(function (c) { if (c.status !== 'archived') { ah(c, c.status, 'archived', actor, 'cascade'); c.status = 'archived'; c.archivedAt = new Date().toISOString(); ca++ } }); var r = { ok: true, task: t }; if (ca) r.childrenArchived = ca; return r }) })
|
|
1067
|
+
// ===== 任务删除通道(真删,无 undo)=====
|
|
1068
|
+
// 背景:archive-task 只收 resolved/cancelled,草稿/误建卡片此前没有任何下线通道(只能永远挂着)。
|
|
1069
|
+
// 状态门禁(delete-task 与 batch-op delete 共用本函数,保证两条入口语义完全一致):
|
|
1070
|
+
// draft / pending / blocked → 允许删(未产生任何执行痕迹,删了不丢信息)
|
|
1071
|
+
// in-progress / verifying → 拒绝,提示先 terminate-agent 终止(避免把在跑的 run 变成孤儿)
|
|
1072
|
+
// resolved / cancelled → 拒绝,引导用 archive-task(已落定任务留档可检索)
|
|
1073
|
+
// archived → 幂等 ok(已不在活跃看板里,重复调用不报错)
|
|
1074
|
+
// 未归档子任务(parentId 指向本任务且 status !== 'archived')存在时拒删:父卡一删子任务的
|
|
1075
|
+
// parentId 就成了悬空引用(checkParentAuto / 上下文继承都会失效),必须先处理子任务。
|
|
1076
|
+
// 返回 { err: '...' } 或 { mode: 'already' };调用方按需转成各自的返回体。
|
|
1077
|
+
function deleteGate(d, t) {
|
|
1078
|
+
if (t.status === 'in-progress' || t.status === 'verifying') return { err: '任务正在执行中,请先用 terminate-agent 终止(in-progress 回待办、verifying 换 verifier 接手)再删除' }
|
|
1079
|
+
if (t.status === 'resolved' || t.status === 'cancelled') return { err: '已落定任务请用归档(archive-task),不要删除' }
|
|
1080
|
+
if (t.status === 'archived') return { mode: 'already' }
|
|
1081
|
+
// 允许态只剩 draft / pending / blocked;其他未知状态(老看板/人工改档)一律拒删,宁可保守
|
|
1082
|
+
if (t.status !== 'draft' && t.status !== 'pending' && t.status !== 'blocked') return { err: '任务状态 ' + t.status + ' 不在可删除范围(仅草稿/待办/阻塞可删)' }
|
|
1083
|
+
var live = gsb(t.id, d.tasks).filter(function (c) { return c.status !== 'archived' })
|
|
1084
|
+
if (live.length) return { err: '该任务还有 ' + live.length + ' 个未归档子任务(' + live.map(function (c) { return c.title || c.id }).slice(0, 3).join('、') + '),请先删除或归档子任务' }
|
|
1085
|
+
return { mode: 'ok' }
|
|
1086
|
+
}
|
|
1087
|
+
// 单任务删除:真删(从 d.tasks 数组移除),不写 history(记录随任务一起消失),不留档、无 undo。
|
|
1088
|
+
// console.error 留一行操作日志,便于事后溯源"某个卡片什么时候被谁删了"。
|
|
1089
|
+
handle('delete-task', async function (args) {
|
|
1090
|
+
var sid = rpcSessionId(args); var actor = getActorId()
|
|
1091
|
+
if (!args.taskId) return { ok: false, error: 'taskId required' }
|
|
1092
|
+
return mutateLocked(sid, function (d) {
|
|
1093
|
+
var t = d.tasks.find(function (x) { return x.id === args.taskId })
|
|
1094
|
+
if (!t) return { ok: false, error: 'not found' }
|
|
1095
|
+
var g = deleteGate(d, t)
|
|
1096
|
+
if (g.mode === 'already') return { ok: true, deleted: t.id, alreadyArchived: true }
|
|
1097
|
+
if (g.err) return { ok: false, error: g.err }
|
|
1098
|
+
d.tasks = d.tasks.filter(function (x) { return x.id !== t.id })
|
|
1099
|
+
console.error('[task-board] delete-task: ' + t.id + ' «' + String(t.title || '').slice(0, 60) + '» (status=' + t.status + ') by ' + actor)
|
|
1100
|
+
return { ok: true, deleted: t.id }
|
|
1101
|
+
})
|
|
1102
|
+
})
|
|
948
1103
|
handle('update-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; if (args.title !== undefined) t.title = args.title; if (args.description !== undefined) t.description = args.description; if (args.priority !== undefined) t.priority = args.priority; if (args.assignMode !== undefined) t.assignMode = args.assignMode; if (args.assignee !== undefined) t.assignee = args.assignee || null; if (args.dependsOn !== undefined) { var derr = validateDeps(d, t.id, args.dependsOn); if (derr) return { ok: false, error: derr }; t.dependsOn = args.dependsOn } if (args.pipeline !== undefined) { t.pipeline = args.pipeline; t.pipelineAuto = false } if (args.contextFiles !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.files = Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : [] } if (args.contextNotes !== undefined) { if (!t.context) t.context = { files: [], docs: [], instructions: '', notes: '', relatedTasks: [], prerequisites: '' }; t.context.notes = typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : '' } if (args.publish) { if (t.status !== 'draft') return { ok: false, error: 'not a draft' }; t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'published') } if (args.unfreeze && t.frozen) { delete t.frozen; delete t.frozenAt; delete t.frozenBy; ah(t, t.status, t.status, actor, '解除冻结,重新进入派发池') } if (args.resetToPending) { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; t.resolvedAt = null; t.resolution = null; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; return { ok: true, task: t } }) })
|
|
949
1104
|
handle('set-board-mode', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { d.boardMode = args.mode === 'manual' ? 'manual' : 'auto'; if (d.boardMode === 'manual' && d.teamMode) { d.teamMode = false; teamModeCache[sid] = false }; return { ok: true, boardMode: d.boardMode, teamMode: !!d.teamMode, workMode: deriveWorkMode(d) } }) })
|
|
950
1105
|
handle('set-team-mode', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { d.teamMode = !!args.enabled; if (d.teamMode) d.boardMode = 'auto'; teamModeCache[sid] = d.teamMode; return { ok: true, teamMode: d.teamMode, boardMode: d.boardMode, workMode: deriveWorkMode(d) } }) })
|
|
@@ -1199,17 +1354,30 @@ export function apply(ctx) {
|
|
|
1199
1354
|
handle('set-board-config', async function (args) { var sid = rpcSessionId(args); return mutateLocked(sid, function (d) { if (args.key === 'maxWorkers') d.maxWorkers = Math.max(1, Math.min(10, args.value || 3)); else if (args.key === 'maxVerifiers') d.maxVerifiers = Math.max(0, Math.min(5, args.value || 0)); else if (args.key === 'workerModel') d.workerModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'verifierModel') d.verifierModel = typeof args.value === 'string' ? args.value.trim() : ''; else if (args.key === 'softTimeoutMin') d.softTimeoutMin = Math.max(1, Math.min(480, Number(args.value) || 30)); else if (args.key === 'hardTimeoutMin') d.hardTimeoutMin = Math.max(1, Math.min(1440, Number(args.value) || 120)); else if (args.key === 'feedbackEnabled') { d.feedbackEnabled = !!args.value; feedbackCache[sid] = d.feedbackEnabled } return { ok: true } }) })
|
|
1200
1355
|
handle('create-task', async function (args) { var sid = rpcSessionId(args); var actor = getActorId(); return mutateLocked(sid, function (d) { if (args.id && d.tasks.find(function (x) { return x.id === args.id })) return { ok: false, error: 'duplicate id' }; if (args.dependsOn && args.dependsOn.length) { var derr = validateDeps(d, args.id || '(pending)', args.dependsOn); if (derr) return { ok: false, error: derr } }; var now = new Date().toISOString(); /* Team 模式护栏:draft 缺省跟随 teamMode(先补齐依赖/上下文再统一 publish);显式 draft:false 保留为立即派发的逃生门 */ var asDraft = args.draft === undefined ? !!d.teamMode : !!args.draft; var t = { id: args.id || ('task-' + Date.now().toString(36)), title: args.title || 'Untitled', description: args.description || '', status: asDraft ? 'draft' : 'pending', priority: args.priority || 'medium', tags: args.tags || [], parentId: args.parentId || null, subtaskStrategy: null, assignMode: 'auto', assignee: null, context: { files: (Array.isArray(args.contextFiles) ? args.contextFiles.map(String).slice(0, 20) : []), docs: [], instructions: args.instructions || '', notes: (typeof args.contextNotes === 'string' ? args.contextNotes.slice(0, 8000) : ''), relatedTasks: [], prerequisites: '' }, acceptance: args.acceptance || '', dependsOn: args.dependsOn || [], touches: normTouches(args.touches), pipeline: args.pipeline || '', claimedBy: null, claimedAt: null, createdAt: now, resolvedAt: null, verifiedAt: null, verifiedBy: null, archivedAt: null, resolution: null, waitingForTouches: null, messages: [], history: [{ from: 'created', to: asDraft ? 'draft' : 'pending', timestamp: now, actor: actor, note: asDraft ? 'created as draft' : 'created' }] }; if (!t.pipeline) { t.pipeline = classifyPipeline(t); t.pipelineAuto = true }; d.tasks.push(t); return withSplitHint({ ok: true, task: t }, t) }) })
|
|
1201
1356
|
handle('list-children', async function (args) { var sid = rpcSessionId(args); var subs = ctx.subagents; if (!subs) return { ok: true, children: [] }; try { var list = await subs.listChildren(sid); var children = (list || []).map(function (c) { return { id: String(c.sessionId || c.id || ''), label: String(c.label || c.title || c.mode || '') } }).filter(function (c) { return c.id.length > 0 }); return { ok: true, children: children } } catch (e) { return { ok: true, children: [], error: String(e) } } })
|
|
1202
|
-
// ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority
|
|
1357
|
+
// ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority(全部)/ delete(真删,无 undo)=====
|
|
1358
|
+
// delete op 与单任务 delete-task 走同一套 deleteGate 门禁(状态 + 未归档子任务),
|
|
1359
|
+
// done/skipped 语义与 archive 完全一致:门禁不过就进 skipped(附 reasons[id] 一行原因)。
|
|
1360
|
+
// 注意:批量删除是**真删**(从 tasks 数组移除),不产生 undo 快照——batch-undo 对 delete 无意义。
|
|
1203
1361
|
handle('batch-op', async function (args) {
|
|
1204
1362
|
var sid = rpcSessionId(args); var actor = getActorId()
|
|
1205
1363
|
var ids = Array.isArray(args.ids) ? args.ids : []
|
|
1206
1364
|
if (ids.length === 0) return { ok: false, error: 'no ids' }
|
|
1207
1365
|
return mutateLocked(sid, function (d) {
|
|
1208
|
-
var done = 0, skipped = []
|
|
1366
|
+
var done = 0, skipped = [], reasons = {}
|
|
1367
|
+
// skip 点统一收口:追加 id + 原因(原因只进 reasons,不改 skipped 的 string[] 老契约)
|
|
1368
|
+
function skip(id, why) { skipped.push(id); if (why) reasons[id] = why }
|
|
1209
1369
|
ids.forEach(function (id) {
|
|
1210
1370
|
var t = d.tasks.find(function (x) { return x.id === id })
|
|
1211
|
-
if (!t) {
|
|
1212
|
-
if (args.op === '
|
|
1371
|
+
if (!t) { skip(id, '任务不存在'); return }
|
|
1372
|
+
if (args.op === 'delete') {
|
|
1373
|
+
var g = deleteGate(d, t)
|
|
1374
|
+
if (g.err) { skip(id, g.err); return }
|
|
1375
|
+
// archived 幂等:已归档的不再删(也不计入 done,与 archive 对已归档项的处理保持一致)
|
|
1376
|
+
if (g.mode === 'already') { skip(id, '已归档'); return }
|
|
1377
|
+
d.tasks = d.tasks.filter(function (x) { return x.id !== id })
|
|
1378
|
+
console.error('[task-board] batch delete: ' + id + ' «' + String(t.title || '').slice(0, 60) + '» (status=' + t.status + ') by ' + actor)
|
|
1379
|
+
done++
|
|
1380
|
+
} else if (args.op === 'archive') {
|
|
1213
1381
|
if (t.status !== 'resolved' && t.status !== 'cancelled') { skipped.push(id); return }
|
|
1214
1382
|
var ps = t.status; t.status = 'archived'; t.archivedAt = new Date().toISOString(); ah(t, ps, 'archived', actor, 'batch archive'); done++
|
|
1215
1383
|
} else if (args.op === 'set-priority') {
|
|
@@ -1220,10 +1388,16 @@ export function apply(ctx) {
|
|
|
1220
1388
|
t.status = 'pending'; ah(t, 'draft', 'pending', actor, 'batch publish'); done++
|
|
1221
1389
|
} else { skipped.push(id) }
|
|
1222
1390
|
})
|
|
1223
|
-
|
|
1391
|
+
// reasons 只在 delete op 下有内容(其他 op 的跳过原因沿用"未命中门禁"的老行为),
|
|
1392
|
+
// 老客户端只读 skipped.length,多一个可选字段无感。
|
|
1393
|
+
return { ok: true, done: done, skipped: skipped, reasons: reasons }
|
|
1224
1394
|
})
|
|
1225
1395
|
})
|
|
1226
1396
|
// #16 批量撤销:按快照恢复 priority(任何状态)与 status(仅 archive→resolved 回滚)
|
|
1397
|
+
// 明确不支持 op='delete':删除是真删(任务对象已从 tasks 数组移除,快照里只剩 id/priority/status),
|
|
1398
|
+
// 没有任何可恢复的原始字段,撤销只能凭空造一张残缺卡片——所以 delete 不产生 undo 快照,
|
|
1399
|
+
// 客户端也不为 delete 显示「↩️ 撤销」按钮(snapshot 只在 done>0 且 op!=='delete' 时保留)。
|
|
1400
|
+
// 若老客户端硬发 op='delete' 快照进来,落到 else 分支只做 priority 回填,不会凭空复活任务。
|
|
1227
1401
|
handle('batch-undo', async function (args) {
|
|
1228
1402
|
var sid = rpcSessionId(args); var actor = getActorId()
|
|
1229
1403
|
var snap = args && args.snapshot
|
package/lib/client.js
CHANGED
|
@@ -84,6 +84,14 @@ function apply(ctx) {
|
|
|
84
84
|
['path', { d: 'M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8' }],
|
|
85
85
|
['path', { d: 'M10 12h4' }]
|
|
86
86
|
],
|
|
87
|
+
// 删除入口图标(草稿/待办/阻塞卡片 hover 小按钮、详情页按钮、批量删除共用)
|
|
88
|
+
'trash-2': [
|
|
89
|
+
['path', { d: 'M3 6h18' }],
|
|
90
|
+
['path', { d: 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6' }],
|
|
91
|
+
['path', { d: 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2' }],
|
|
92
|
+
['path', { d: 'M10 11v6' }],
|
|
93
|
+
['path', { d: 'M14 11v6' }]
|
|
94
|
+
],
|
|
87
95
|
'package': [
|
|
88
96
|
['path', { d: 'M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z' }],
|
|
89
97
|
['path', { d: 'M12 22V12' }],
|
|
@@ -213,7 +221,7 @@ function apply(ctx) {
|
|
|
213
221
|
}
|
|
214
222
|
var COLUMNS = ['draft', 'pending', 'in-progress', 'verifying', 'resolved', 'blocked']
|
|
215
223
|
var reqEpoch = 0 // 会话切换纪元:切会话时自增,旧会话在途响应按纪元丢弃
|
|
216
|
-
var state = { sessionId: null, tasks: [], boardMode: 'auto', teamMode: false, workMode: 'auto', minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, feedbackEnabled: true, lessonPushed: {}, isRoot: true, open: false, detailId: null, children: [], dragOver: null, dragTask: null, dispatchInfo: '', view: 'board', layoutLeft: 280, layoutRight: 0, poolStatus: null, escalatedIds: [], filterQ: '', filterPrio: [], filterTag: '', selectMode: false, selected: {}, undoSnapshot: null, archSort: 'time-desc', dateRange: { from: '', to: '' }, rfOpen: false, gboOpen: false, activity: {}, archived: [], archQ: '', globalBoards: [], createOpen: false, createFlash: '', usageSummary: null }
|
|
224
|
+
var state = { sessionId: null, tasks: [], boardMode: 'auto', teamMode: false, workMode: 'auto', minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, feedbackEnabled: true, lessonPushed: {}, isRoot: true, open: false, detailId: null, children: [], dragOver: null, dragTask: null, dispatchInfo: '', view: 'board', layoutLeft: 280, layoutRight: 0, poolStatus: null, escalatedIds: [], filterQ: '', filterPrio: [], filterTag: '', selectMode: false, selected: {}, undoSnapshot: null, cardHover: '', archSort: 'time-desc', dateRange: { from: '', to: '' }, rfOpen: false, gboOpen: false, activity: {}, archived: [], archQ: '', globalBoards: [], createOpen: false, createFlash: '', usageSummary: null }
|
|
217
225
|
// #16 快捷键:Esc 逐级关闭(详情→看板→面板);输入框聚焦时不劫持
|
|
218
226
|
try {
|
|
219
227
|
var onKey = function (e) {
|
|
@@ -343,6 +351,24 @@ function apply(ctx) {
|
|
|
343
351
|
else if (to === 'blocked' && from === 'in-progress') { var r = window.prompt('阻塞原因(可选):') || ''; rpc('resolve-task', { taskId: taskId, status: 'blocked', resolution: r }).then(ok).catch(fail) }
|
|
344
352
|
else if (to === 'archived' && from === 'resolved') rpc('archive-task', { taskId: taskId }).then(ok).catch(fail)
|
|
345
353
|
}
|
|
354
|
+
// ===== 删除通道(与 host delete-task / batch-op delete 门禁一一对应)=====
|
|
355
|
+
// 可删状态:草稿/待办/阻塞(未产生执行痕迹)。in-progress/verifying 需先终止;resolved/cancelled 引导归档;
|
|
356
|
+
// 有未归档子任务时 host 会拒删——客户端只做"按钮是否出现"的粗筛,真正的门禁以 host 返回的 error 为准。
|
|
357
|
+
function canDelete(t) { return !!t && (t.status === 'draft' || t.status === 'pending' || t.status === 'blocked') }
|
|
358
|
+
// 误删防呆:真删不可恢复,必须先过 window.confirm(详情页/卡片/批量三处入口共用同一句提示语)
|
|
359
|
+
// 失败时把 host 的中文门禁原因原样回显(如"请先用 terminate-agent 终止"),不做二次翻译。
|
|
360
|
+
function deleteTask(id, title, onMsg) {
|
|
361
|
+
var t = getTask(id)
|
|
362
|
+
var name = title || (t && t.title) || id
|
|
363
|
+
if (!window.confirm('删除不可恢复,确认删除「' + name + '」?')) return Promise.resolve({ ok: false, cancelled: true })
|
|
364
|
+
return rpc('delete-task', { taskId: id }).then(function (r) {
|
|
365
|
+
if (r && r.ok === false) { if (onMsg) onMsg('⚠️ ' + (r.error || '删除失败')) }
|
|
366
|
+
else if (onMsg) onMsg('🗑 已删除「' + name + '」')
|
|
367
|
+
if (state.detailId === id) state.detailId = null // 详情页开着被删任务 → 关掉,避免下一帧渲染"任务不存在"
|
|
368
|
+
fetchTasks()
|
|
369
|
+
return r
|
|
370
|
+
}).catch(function (e) { if (onMsg) onMsg('⚠️ ' + String(e)); return { ok: false, error: String(e) } })
|
|
371
|
+
}
|
|
346
372
|
function onDragStart(e, task) { e.dataTransfer.setData('text/plain', JSON.stringify({ id: task.id, status: task.status })); e.dataTransfer.effectAllowed = 'move'; state.dragTask = task.id; notify() }
|
|
347
373
|
function onDragEnd() { state.dragTask = null; state.dragOver = null; notify() }
|
|
348
374
|
function onColDragOver(e, st) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (state.dragOver !== st) { state.dragOver = st; notify() } }
|
|
@@ -708,7 +734,7 @@ function apply(ctx) {
|
|
|
708
734
|
}
|
|
709
735
|
function elapsedSince(iso) { var m = elapsedMin(iso); if (!m) return '<1m'; if (m < 60) return m + 'm'; var h = Math.floor(m / 60); return h + 'h' + (m % 60 ? (m % 60) + 'm' : '') }
|
|
710
736
|
function pipeOf(t) { return pipeMeta[t.pipeline] || pipeMeta.full }
|
|
711
|
-
// #14 批量操作条(#16 带一步撤销:快照操作前的 priority/status
|
|
737
|
+
// #14 批量操作条(#16 带一步撤销:快照操作前的 priority/status;delete 是真删,明确不支持撤销)
|
|
712
738
|
function BatchBar() {
|
|
713
739
|
var _R = React; var useState = _R.useState, useEffect = _R.useEffect
|
|
714
740
|
var _a = useState(0), cnt = _a[0], setCnt = _a[1]; var _b = useState(false), on = _b[0], setOn = _b[1]; var _c = useState(''), msg = _c[0], setMsg = _c[1]; var _d = useState(null), undo = _d[0], setUndo = _d[1]
|
|
@@ -716,14 +742,36 @@ function apply(ctx) {
|
|
|
716
742
|
if (!on) return null
|
|
717
743
|
var ids = Object.keys(state.selected)
|
|
718
744
|
function snapshot(op) { return { op: op, at: Date.now(), items: ids.map(function (id) { var t = getTask(id); return t ? { id: id, priority: t.priority, status: t.status } : null }).filter(Boolean) } }
|
|
719
|
-
|
|
745
|
+
// 跳过原因只回显第一条(如"任务正在执行中,请先…"),避免把整屏门禁文案塞进批量条
|
|
746
|
+
function firstReason(r) { var m = (r && r.reasons) || {}; var ks = Object.keys(m); return ks.length ? String(m[ks[0]]) : '' }
|
|
747
|
+
function run(op, value) {
|
|
748
|
+
// 只有可撤销的 op 才需要快照(delete 是真删,快照里的 status/priority 复活不回整张卡)
|
|
749
|
+
var snap = op === 'delete' ? null : snapshot(op)
|
|
750
|
+
var payload = { ids: ids, op: op }; if (value !== undefined) payload.value = value
|
|
751
|
+
rpc('batch-op', payload).then(function (r) {
|
|
752
|
+
state.selected = {}
|
|
753
|
+
// 删除无 undo:done>0 也不留撤销快照(否则「↩️ 撤销」点了恢复不了,纯粹误导);其他 op 维持一步撤销
|
|
754
|
+
state.undoSnapshot = (r && r.done > 0 && op !== 'delete') ? snap : null
|
|
755
|
+
var reason = (op === 'delete' && r && r.reasons) ? firstReason(r) : ''
|
|
756
|
+
setMsg('✅ 已处理 ' + (r && r.done || 0) + ' 个' + (r && r.skipped && r.skipped.length ? ',跳过 ' + r.skipped.length + (reason ? '(' + reason + ')' : '') : ''))
|
|
757
|
+
fetchTasks()
|
|
758
|
+
}).catch(function (e) { setMsg('⚠️ ' + String(e)) })
|
|
759
|
+
}
|
|
760
|
+
// 批量删除:真删不可恢复,必须先 confirm;确认后走 batch-op delete,逐 id 受 host 同一套状态门禁约束
|
|
761
|
+
function batchDelete() {
|
|
762
|
+
if (cnt === 0) return
|
|
763
|
+
if (!window.confirm('批量删除不可恢复,确认删除选中的 ' + cnt + ' 个任务?\n(执行中/验证中的会被跳过,需先终止;已落定任务请改用归档)')) return
|
|
764
|
+
run('delete')
|
|
765
|
+
}
|
|
720
766
|
function doUndo() { if (!undo) return; rpc('batch-undo', { snapshot: undo }).then(function (r) { setMsg('↩️ 已撤销 ' + (r && r.done || 0) + ' 个'); state.undoSnapshot = null; fetchTasks() }).catch(function (e) { setMsg('⚠️ ' + String(e)) }) }
|
|
721
767
|
var btn = { fontSize: 10, padding: '3px 10px', borderRadius: 4, border: 'none', cursor: 'pointer', fontWeight: 600 }
|
|
722
768
|
return React.createElement('div', { style: { position: 'sticky', bottom: 0, display: 'flex', alignItems: 'center', gap: 6, padding: '6px 8px', marginTop: 8, background: C.nested, border: '1px solid ' + C.border, borderRadius: 6 } },
|
|
723
769
|
React.createElement('span', { style: { fontSize: 11, color: C.text, fontWeight: 600 } }, '已选 ' + cnt + ' 项'),
|
|
724
770
|
React.createElement('button', { onClick: function () { run('archive') }, disabled: cnt === 0, style: Object.assign({}, btn, { background: C.text2, color: C_INV, display: 'inline-flex', alignItems: 'center', gap: 3 }) }, ic('archive', 11), '批量归档'),
|
|
725
771
|
['critical', 'high', 'medium', 'low'].map(function (p) { return React.createElement('button', { key: p, onClick: function () { run('set-priority', p) }, disabled: cnt === 0, style: Object.assign({}, btn, { background: prioColor[p], color: C_INV }) }, prioLabel[p]) }),
|
|
726
|
-
|
|
772
|
+
// 批量删除(drop 语义):红底实心 + confirm 防误删;执行中/验证中与有未归档子任务的会被 host 跳过
|
|
773
|
+
React.createElement('button', { onClick: batchDelete, disabled: cnt === 0, title: '删除选中任务(不可恢复;执行中/验证中的需先终止,已落定请用归档)', style: Object.assign({}, btn, { background: C.err, color: C_INV, display: 'inline-flex', alignItems: 'center', gap: 3 }) }, ic('trash-2', 11), '批量删除'),
|
|
774
|
+
undo ? React.createElement('button', { onClick: doUndo, title: '撤销最近一次批量操作(删除不可撤销)', style: Object.assign({}, btn, { background: C.brand, color: C_INV }) }, '↩️ 撤销') : null,
|
|
727
775
|
msg ? React.createElement('span', { style: { fontSize: 10, color: C.text2 } }, msg) : null,
|
|
728
776
|
React.createElement('button', { onClick: function () { state.selected = {}; notify() }, style: { marginLeft: 'auto', fontSize: 10, padding: '3px 8px', border: 'none', background: 'transparent', color: C.text2, cursor: 'pointer' } }, '清除选择'))
|
|
729
777
|
}
|
|
@@ -742,14 +790,25 @@ function apply(ctx) {
|
|
|
742
790
|
allTags().length > 0 ? React.createElement('select', { value: ft, onChange: function (e) { state.filterTag = e.target.value; notify() }, style: { fontSize: 10, padding: '2px 4px', border: '1px solid ' + C.border, borderRadius: 4, background: C.card, color: C.text } }, React.createElement('option', { value: '' }, '🏷 全部标签'), allTags().map(function (g) { return React.createElement('option', { key: g, value: g }, g) })) : null,
|
|
743
791
|
hasFilter ? React.createElement('button', { onClick: clearAll, style: { fontSize: 10, padding: '2px 8px', borderRadius: 10, border: 'none', background: C.nested, color: C.text2, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 2 } }, ic('x', 9), '清除') : null)
|
|
744
792
|
}
|
|
745
|
-
|
|
793
|
+
// 卡片删除入口的 hover 态(值形如 "taskId|del",未 hover 时为空串):
|
|
794
|
+
// 只用于控制垃圾桶按钮的淡入/淡出(平时 opacity:0 不抢视觉),不参与拖拽/选中逻辑。
|
|
795
|
+
function isCardHover(id, kind) { return String(state.cardHover || '') === (id + '|' + kind) }
|
|
796
|
+
function setHover(id, kind) { var v = id ? (id + '|' + kind) : ''; if (state.cardHover !== v) { state.cardHover = v; notify() } }
|
|
797
|
+
function Card(props) { var t = props.task; var pc = prioColor[t.priority] || prioColor.low; var dragging = state.dragTask === t.id; var preview = (t.deliverable && t.deliverable.summary) || t.resolution; var critGlow = t.priority === 'critical' && !t.escalation; var sel = !!state.selected[t.id]; var pm = pipeOf(t); var depBlock = t.status === 'pending' && depsBlocked(t); var delOk = !state.selectMode && canDelete(t); return React.createElement('div', { draggable: !state.selectMode, onDragStart: function (e) { onDragStart(e, t) }, onDragEnd: onDragEnd, onMouseEnter: function () { if (delOk) setHover(t.id, 'del') }, onMouseLeave: function () { if (delOk) setHover('', '') }, onClick: function () { if (state.selectMode) { if (state.selected[t.id]) delete state.selected[t.id]; else state.selected[t.id] = true; notify() } else { state.detailId = t.id; notify() } }, style: { border: '1px solid ' + (sel ? C.brand : (t.escalation ? C.err : (critGlow ? C.err : C.border))), borderRadius: 6, padding: '6px 8px', marginBottom: 6, background: sel ? C.nested : C.card, borderLeft: '3px solid ' + (t.escalation ? C.err : pc), cursor: state.selectMode ? 'pointer' : 'grab', fontSize: 12, opacity: dragging ? 0.4 : (depBlock ? 0.65 : 1), transition: 'opacity .15s', animation: critGlow ? 'tskb-crit 2s ease-in-out infinite' : 'none' } }, React.createElement('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 4 } }, state.selectMode ? React.createElement('span', { style: { color: sel ? C.brand : C.text2, flexShrink: 0, marginTop: 1, display: 'inline-flex' } }, ic(sel ? 'square-check-big' : 'square', 12)) : null, React.createElement('div', { style: { fontWeight: 600, color: C.text, marginBottom: 2, wordBreak: 'break-word', flex: 1 } }, t.title), React.createElement('span', { style: { flexShrink: 0, marginTop: 1, display: 'inline-flex', color: C.text2 }, title: pm.label }, ic(pm.icon, 10)), React.createElement('span', { style: { fontSize: 9, padding: '1px 5px', borderRadius: 3, background: 'color-mix(in srgb, ' + pc + ' 20%, transparent)', color: pc, flexShrink: 0, marginTop: 1 } }, prioLabel[t.priority] || '中'), (t.usage && t.usage.total) ? React.createElement('span', { style: { fontSize: 9, color: C.text2, flexShrink: 0, marginTop: 1 }, title: '本任务累计 token:' + String(t.usage.total) + '(' + (t.usage.runs || 0) + ' 次 run)' }, '⛁ ' + fmtTokens(t.usage.total)) : null), t.escalation ? React.createElement('div', { style: { fontSize: 10, color: C.err, fontWeight: 600, marginBottom: 2, display: 'flex', alignItems: 'center', gap: 3 } }, ic('alert-triangle', 10), '待裁决 — 点击查看疑问') : null, depBlock ? React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 2 } }, '⛓ 被 ' + t.dependsOn.filter(function (id) { var d = getTask(id); return !d || (d.status !== 'resolved' && d.status !== 'archived') }).length + ' 个依赖阻塞') : null, t.stuckSince ? React.createElement('div', { style: { fontSize: 10, color: C.warn, fontWeight: 600, marginBottom: 2, animation: 'tskb-pulse 1.5s ease-in-out infinite' } }, '⏱ 疑似卡死 · ' + ago(t.stuckSince) + ' — 点击处理') : null, React.createElement('div', { style: { fontSize: 10, color: C.text2, display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' } },
|
|
746
798
|
t.frozen ? React.createElement('span', { title: '已冻结:不参与自动派发(详情页可「解除冻结」)', style: { color: C.brand, fontWeight: 600 } }, '❄ 冻结') : null,
|
|
747
799
|
(Array.isArray(t.waitingForTouches) && t.waitingForTouches.length) ? React.createElement('span', { title: '等文件锁释放:' + t.waitingForTouches.join('、') + '(touches 冲突,详情页可 force 越权派发)', style: { color: C.warn, fontWeight: 600 } }, '🔒 等文件释放') : null,
|
|
748
800
|
React.createElement('span', { title: pm.label, style: { fontSize: 9, padding: '0 4px', borderRadius: 2, background: C.nested, border: '1px solid ' + C.border } }, pm.short),
|
|
749
801
|
(t.retryCount || 0) + (t.rejectCount || 0) > 0 ? React.createElement('span', { title: '重试 ' + (t.retryCount || 0) + ' 次 / 驳回 ' + (t.rejectCount || 0) + ' 次', style: { color: C.warn, fontWeight: 600 } }, '⟳' + ((t.retryCount || 0) + (t.rejectCount || 0))) : null,
|
|
750
802
|
t.status === 'in-progress' && t.claimedBy ? React.createElement('span', null, '⚡ ' + shortId(t.claimedBy)) : null,
|
|
751
803
|
t.assignee ? React.createElement('span', null, '👤→' + shortId(t.assignee)) : null,
|
|
752
|
-
durOf(t) ? React.createElement('span', { title: '创建至今耗时' }, '⏱ ' + durOf(t)) : null
|
|
804
|
+
durOf(t) ? React.createElement('span', { title: '创建至今耗时' }, '⏱ ' + durOf(t)) : null,
|
|
805
|
+
// 删除入口(仅草稿/待办/阻塞,多选模式下隐藏以免误触):hover 才由透明转红,平时不抢视觉
|
|
806
|
+
delOk ? React.createElement('span', {
|
|
807
|
+
onClick: function (e) { e.stopPropagation(); deleteTask(t.id, t.title) }, // 阻止冒泡:不打开详情页
|
|
808
|
+
onMouseDown: function (e) { e.stopPropagation() }, // 也不触发卡片拖拽
|
|
809
|
+
title: '删除任务(不可恢复;执行中请先终止,已落定请用归档)',
|
|
810
|
+
style: { flexShrink: 0, marginTop: 1, display: 'inline-flex', cursor: 'pointer', color: C.err, opacity: isCardHover(t.id, 'del') ? 1 : 0, transition: 'opacity .15s' }
|
|
811
|
+
}, ic('trash-2', 11)) : null),
|
|
753
812
|
(t.status === 'in-progress' || t.status === 'verifying') && state.activity[t.id] ? React.createElement('div', { style: { fontSize: 10, color: C.brand, marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: state.activity[t.id] }, '👁 ' + state.activity[t.id]) : null,
|
|
754
813
|
// 里程碑进展(Worker 主动上报的轻量进展,kind=progress):进行中的卡片显示最新一条 + 相对时间,无则不显示
|
|
755
814
|
t.status === 'in-progress' && t.lastProgress && t.lastProgress.text ? React.createElement('div', { style: { fontSize: 10, color: C.text2, marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: '最近进展(' + (t.lastProgress.by || '') + ' · ' + fmtTime(t.lastProgress.at) + '):' + t.lastProgress.text }, '📈 ' + t.lastProgress.text + ' · ' + ago(t.lastProgress.at)) : null,
|
|
@@ -1070,6 +1129,10 @@ function apply(ctx) {
|
|
|
1070
1129
|
task.status === 'verifying' ? React.createElement('button', { onClick: function () { doAction(function () { return rpc('verify-task', { taskId: task.id, verdict: 'approved' }) }) }, style: { fontSize: 10, padding: '3px 8px', border: 'none', borderRadius: 3, background: C.ok, color: C_INV, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('check-circle', 10), '通过') : null,
|
|
1071
1130
|
task.status === 'verifying' ? React.createElement('button', { onClick: function () { var r = window.prompt('驳回原因:'); doAction(function () { return rpc('verify-task', { taskId: task.id, verdict: 'rejected', comment: r || '' }) }) }, style: { fontSize: 10, padding: '3px 8px', border: 'none', borderRadius: 3, background: C.err, color: C_INV, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('x-circle', 10), '驳回') : null,
|
|
1072
1131
|
task.status === 'resolved' ? React.createElement('button', { onClick: function () { doAction(function () { return rpc('archive-task', { taskId: task.id }) }) }, style: { fontSize: 10, padding: '3px 8px', border: 'none', borderRadius: 3, background: C.text2, color: C_INV, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('archive', 10), '归档') : null),
|
|
1132
|
+
// 删除(仅草稿/待办/阻塞):confirm 防误删;被 host 门禁拒绝时把原因回显到详情页 actionMsg
|
|
1133
|
+
// 容器 div 的收尾括号已挂在「归档」行;删除按钮作为**额外一行元素**排在它后面
|
|
1134
|
+
// (多一个实参给同一个 createElement,属于合法调用),因此本行自身必须配平。
|
|
1135
|
+
canDelete(task) ? React.createElement('button', { onClick: function () { deleteTask(task.id, task.title, setActionMsg) }, title: '删除任务(不可恢复;执行中请先终止,已落定请用归档)', style: { fontSize: 10, padding: '3px 8px', border: '1px solid ' + C.err, borderRadius: 3, background: 'transparent', color: C.err, cursor: 'pointer', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 3 } }, ic('trash-2', 10), '删除') : null,
|
|
1073
1136
|
(function () {
|
|
1074
1137
|
// 历史会话:无论任务处于哪个阶段都完整列出(Worker 各次 + Verifier 各次)
|
|
1075
1138
|
var runs = historyRuns(task)
|
package/lib/core.mjs
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// dsh-agent-board — 纯逻辑核心(lib/core.mjs)
|
|
2
2
|
// 不依赖任何 ctx/服务/IO:任务状态机、依赖校验、管线分类、prompt 构建、输出解析。
|
|
3
3
|
// index.mjs(IO 编排层)从这里 import;单元测试直接跑本文件(node --test)。
|
|
4
|
+
// 例外:boardDirName/boardHome 只做纯路径拼接(读 env,不碰磁盘),放在这里是为了让
|
|
5
|
+
// 「临时 HOME」可被自测注入——os.homedir() 进程内首次调用即缓存,晚改 env 无效。
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
4
8
|
|
|
5
9
|
// ===== 常量 =====
|
|
6
10
|
export const MAX_CLAIMED = 3
|
|
@@ -115,10 +119,24 @@ export function cfg(d) {
|
|
|
115
119
|
return { minWorkers: Math.max(0, Math.min(10, d.minWorkers || 1)), maxWorkers: Math.max(1, Math.min(10, d.maxWorkers || 3)), minVerifiers: Math.max(0, Math.min(5, d.minVerifiers || 0)), maxVerifiers: Math.max(0, Math.min(5, d.maxVerifiers || 2)), softTimeoutMin: soft, hardTimeoutMin: hard, feedbackEnabled: d.feedbackEnabled !== false }
|
|
116
120
|
}
|
|
117
121
|
|
|
122
|
+
// ===== 看板数据目录(跨重启继承用)=====
|
|
123
|
+
// 单独抽成纯函数,是为了让「临时 HOME」可被测试注入:
|
|
124
|
+
// os.homedir() 在进程内**首次调用即缓存**,等到测试里再改 env 已经晚了(拿到的是真实家目录);
|
|
125
|
+
// 每次调用都读一遍 env,才能让自测脚本用 temp 目录隔离真实看板(scripts/self-config-inherit.cjs)。
|
|
126
|
+
// 优先顺序与 Node 的 homedir 一致:HOME(类 Unix / 显式覆盖)→ USERPROFILE(Windows)→ os.homedir() 兜底。
|
|
127
|
+
export function boardDirName() { return (process.env.HOME || process.env.USERPROFILE || os.homedir() || '.') }
|
|
128
|
+
export function boardHome() { return path.join(boardDirName(), '.dsh') }
|
|
129
|
+
|
|
118
130
|
// ===== 看板文件种子 =====
|
|
119
131
|
// poolStatus 必须始终在种子/归一化里存在:task_list 工具输出 poolStatus: d.poolStatus,
|
|
120
132
|
// 缺字段 = undefined → 工具结果的 lossless-JSON 校验会拒("value is not lossless JSON")
|
|
121
|
-
|
|
133
|
+
// ownerCwd(跨重启继承):创建该看板的会话工作区路径,继承判定全靠它——取不到就省略字段
|
|
134
|
+
// (绝不落空串,否则「路径读不到的多个会话」会被误判成同一工作区)。
|
|
135
|
+
export function seed(sid, ownerCwd) {
|
|
136
|
+
var d = { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, feedbackEnabled: true, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, poolStatus: { workers: [], verifiers: [] }, tasks: [] }
|
|
137
|
+
if (typeof ownerCwd === 'string' && ownerCwd) d.ownerCwd = ownerCwd
|
|
138
|
+
return d
|
|
139
|
+
}
|
|
122
140
|
// 旧文件缺 poolStatus 的归一化(读路径兜底,保证任何历史文件都满足工具输出契约)
|
|
123
141
|
// touches 兼容:老任务没有该字段照常(这里只把「存在但非数组」的脏值收敛成数组,
|
|
124
142
|
// 避免 holdsFiles/touchesConflict 里 Array.isArray 判定之外还有第三种形态)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agent-board",
|
|
3
3
|
"description": "DeepSeek Harness 智能看板:任务卡片 + 一次性 Worker/Verifier 派发 + 依赖调度 + 管线分档 + Team 模式(歧义上报主窗口裁决)",
|
|
4
|
-
"version": "1.5.
|
|
4
|
+
"version": "1.5.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
7
7
|
"scripts": {
|
|
@@ -43,7 +43,11 @@
|
|
|
43
43
|
"kanban",
|
|
44
44
|
"agent",
|
|
45
45
|
"subagent",
|
|
46
|
-
"
|
|
46
|
+
"multi-agent",
|
|
47
|
+
"ai-agent",
|
|
48
|
+
"task-dispatch",
|
|
49
|
+
"workflow",
|
|
50
|
+
"orchestration"
|
|
47
51
|
],
|
|
48
52
|
"repository": {
|
|
49
53
|
"type": "git",
|