dsh-agent-board 1.7.7 → 1.7.9
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 +14 -5
- package/index.mjs +1 -0
- package/lib/cleanup.mjs +256 -0
- package/lib/client/board-list.js +12 -8
- package/lib/client/dashboard.js +133 -7
- package/lib/client/kernel.js +62 -10
- package/lib/client/task-detail.js +13 -5
- package/lib/client.js +220 -30
- package/lib/core.mjs +30 -2
- package/lib/dispatch.mjs +82 -5
- package/lib/rpc.mjs +154 -25
- package/lib/store.mjs +86 -62
- 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 +294 -18
- package/test/helpers/mock-ctx.mjs +7 -2
- package/test/scoreboard-trends-client.test.mjs +21 -0
- package/test/store-data-safety.test.mjs +105 -0
- package/test/ui-zoom-client.test.mjs +76 -0
- package/test/verify-timing.test.mjs +26 -0
package/lib/dispatch.mjs
CHANGED
|
@@ -12,7 +12,7 @@ const { ah, cfg, claimApply, resolveApply, verifyApply, lateVerdictApply, parseS
|
|
|
12
12
|
export function createDispatch(ctx, state, deps) {
|
|
13
13
|
// 宿主 fs 句柄与「会话工作区解析根」随预研注入瘦身退役(task-muvjs392):派发侧不再读盘——
|
|
14
14
|
// 清单只给「路径:L行号 — 一句用途」,文件内容由 Worker 自己用 read 工具按行号范围自取。
|
|
15
|
-
var rt = deps.rt,
|
|
15
|
+
var rt = deps.rt, mutateLocked = deps.mutateLocked, kickCycle = deps.kickCycle
|
|
16
16
|
var rootForSession = deps.rootForSession, withTimeout = deps.withTimeout, runsFor = deps.runsFor, feedbackOn = deps.feedbackOn
|
|
17
17
|
// 会话日志根(可选注入):usage 增量结算(卡3)必须「真读真日志」才测得出重复计账——
|
|
18
18
|
// 生产不注入(usage.mjs 回退 os.homedir()),单测注入临时目录(否则只能开子进程改 HOME,
|
|
@@ -1001,6 +1001,67 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1001
1001
|
}
|
|
1002
1002
|
}
|
|
1003
1003
|
|
|
1004
|
+
// ===== 僵尸 run 留档清扫(task-mv22z61v):关账「落定卡上的 running 无 endedAt 留档」+ 清卡面认领位 =====
|
|
1005
|
+
// 事故(用户实证):0.4.1/0.4.5 时代老卡,宿主多次重启后 run 留档停在 outcome:'running' 无 endedAt +
|
|
1006
|
+
// 卡面 verifierRun/claimedBy 残留指向死会话。归档门禁 activeRunOf 的卡面兜底(liveEntry 认 claimedBy/
|
|
1007
|
+
// verifierRun 匹配的 running 留档)把它们误判活跃 → archive 永久拒。mv1pqy27 的 doTerminate 状态无关
|
|
1008
|
+
// 清理只覆盖「rec 在内存表」场景;重启后 rec 消失 + 卡已落定的组合落入盲区(resolved 无分支,什么都不清),
|
|
1009
|
+
// 且没有任何工具能关一条无 rec 的 run 留档本体。
|
|
1010
|
+
// 修法:扫描本板卡片的 runs[],凡 outcome==='running' 且无 endedAt 且该 run id 不在内存活跃表(runsFor)
|
|
1011
|
+
// 且所属卡已落定(resolved/cancelled/archived)→ 关账(endedAt=now,outcome 改 'stale-closed',注记
|
|
1012
|
+
// note='host 重启僵尸留档清扫')+ 清对应卡面认领位(verifierRun/claimedBy 若指向它)。
|
|
1013
|
+
// 边界(宁少勿多):
|
|
1014
|
+
// ① 只对落定卡做——在飞卡的 running 留档是 reconcile / 结算通道的辖区,别越界;
|
|
1015
|
+
// ② 无活跃 rec 才扫——内存表里仍活着(!settled)的 run 留给结算通道 / reapGhostRecs 关账(它们用
|
|
1016
|
+
// 'completed'/'timeout-error' 等真结局,而非这里的 'stale-closed',口径更准);
|
|
1017
|
+
// ③ 幂等——关账后 outcome!=running,二次运行零改动;回调无改动时返回 null 不写盘(沿用空闲快进的
|
|
1018
|
+
// 「不写盘」纪律,安静板不因清扫产生 15s 心跳写盘);
|
|
1019
|
+
// ④ 留痕——history 行 + console 一行(有产出时)。
|
|
1020
|
+
// 'stale-closed' 是既有枚举之外的新结局值:详情页/聚合端对未知 outcome 一律按「已结束(有 endedAt)」
|
|
1021
|
+
// 兜底展示,不会把它误判成 running(running 的判据是 outcome==='running' 且无 endedAt,二者已同时改写)。
|
|
1022
|
+
async function sweepZombieRuns(sid, snap) {
|
|
1023
|
+
if (!snap || snap.__noPersist || !Array.isArray(snap.tasks)) return 0 // 瞬时读盘失败/空板:跳过,不写盘
|
|
1024
|
+
// 预扫快照:有候选(落定卡 + running 无 endedAt 留档)才进锁写;否则零 mutateLocked 调用——
|
|
1025
|
+
// 空板/安静板走空闲快进「不写盘」的纪律不受清扫破坏(每 15s 心跳也不能因清扫多出一次写盘调用)。
|
|
1026
|
+
var hasCandidate = false
|
|
1027
|
+
for (var pi = 0; pi < snap.tasks.length && !hasCandidate; pi++) {
|
|
1028
|
+
var pt = snap.tasks[pi]
|
|
1029
|
+
if (!pt || (pt.status !== 'resolved' && pt.status !== 'cancelled' && pt.status !== 'archived')) continue
|
|
1030
|
+
var prs = Array.isArray(pt.runs) ? pt.runs : []
|
|
1031
|
+
for (var pj = 0; pj < prs.length; pj++) { if (prs[pj] && prs[pj].outcome === 'running' && !prs[pj].endedAt) { hasCandidate = true; break } }
|
|
1032
|
+
}
|
|
1033
|
+
if (!hasCandidate) return 0
|
|
1034
|
+
var runs = runsFor(sid)
|
|
1035
|
+
var swept = []
|
|
1036
|
+
await mutateLocked(sid, function (d) {
|
|
1037
|
+
if (!d || !Array.isArray(d.tasks)) return null
|
|
1038
|
+
var any = false
|
|
1039
|
+
d.tasks.forEach(function (t) {
|
|
1040
|
+
if (!t) return
|
|
1041
|
+
if (t.status !== 'resolved' && t.status !== 'cancelled' && t.status !== 'archived') return // 只清扫已落定卡
|
|
1042
|
+
if (!Array.isArray(t.runs)) return
|
|
1043
|
+
var cardChanged = false
|
|
1044
|
+
t.runs.forEach(function (r) {
|
|
1045
|
+
if (!r || r.outcome !== 'running' || r.endedAt) return // 只关「还开着且无 endedAt」的条目
|
|
1046
|
+
var rid = String(r.id)
|
|
1047
|
+
var rec = runs[t.id]
|
|
1048
|
+
if (rec && !rec.settled && String(rec.id) === rid) return // 内存活跃表里还活着 → 结算通道辖区,不扫
|
|
1049
|
+
r.outcome = 'stale-closed'
|
|
1050
|
+
r.endedAt = new Date().toISOString()
|
|
1051
|
+
r.note = 'host 重启僵尸留档清扫'
|
|
1052
|
+
if (t.verifierRun === rid) { t.verifierRun = null; delete t.verifierRunAt }
|
|
1053
|
+
if (t.claimedBy === rid) { t.claimedBy = null; t.claimedAt = null }
|
|
1054
|
+
swept.push(t.id + '/' + rid + '/' + (r.role || '?'))
|
|
1055
|
+
cardChanged = true
|
|
1056
|
+
})
|
|
1057
|
+
if (cardChanged) { ah(t, t.status, t.status, 'system', '僵尸 run 留档已关账(host 重启清扫)'); any = true }
|
|
1058
|
+
})
|
|
1059
|
+
return any ? d : null // 无改动返回 null → 不写盘(安静板不因清扫产生心跳写盘)
|
|
1060
|
+
}, true) // skipKick:本轮已是 poolCycle,无需再踢
|
|
1061
|
+
if (swept.length) console.log('[task-board] 僵尸 run 留档清扫 ' + swept.length + ' 条:' + swept.join(', '))
|
|
1062
|
+
return swept.length
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1004
1065
|
// ===== 幽灵占位回收(池冻结根修,task-muwlepg6)=====
|
|
1005
1066
|
// 病根(活体实证):活跃表项(rec)的**唯一**摘除点是各结算通道(事件通道 settleRun /
|
|
1006
1067
|
// 上报通道 settleReportedRun / 手动终止)。任何一条漏走(worker 经 board_report 完成但当时
|
|
@@ -1112,9 +1173,13 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1112
1173
|
// 位置取舍:root 闸门必须是第一件事(无活 root 的板零 IO),所以本段只能排在早退闸门与 rt 读盘之后;
|
|
1113
1174
|
// 而重建出来的 rec 仍在本轮被读到(活跃计数 / 池状态快照都在下面才求值),语义不受影响。
|
|
1114
1175
|
try { await reconcileRoot(sid) } catch (e) { console.error('[task-board] reconcile 失败:', String(e)) }
|
|
1176
|
+
// 僵尸 run 留档清扫(task-mv22z61v):位置在 reconcile 之后、幽灵回收与空闲快进之前。
|
|
1177
|
+
// 落定卡的僵尸留档无活跃 rec,reconcile(只在飞卡)/reapGhostRecs(只清内存表项)都管不到它,
|
|
1178
|
+
// 而它若留在 running 会卡死 archive;安静板(只有已落定卡)会走空闲快进早退,清扫必须排在其前才能兜到。
|
|
1179
|
+
try { await sweepZombieRuns(sid, snap) } catch (e) { console.error('[task-board] 僵尸留档清扫失败:', String(e)) }
|
|
1115
1180
|
// 幽灵占位回收(池冻结根修):必须排在活跃度计数与 pickDispatch 之前——残留表项正是把 capW 拉到 0、
|
|
1116
1181
|
// 并把卡挡在 Verifier 派发之外的元凶(见 reapGhostRecs 头注释)。收尾会写盘(结局/usage),
|
|
1117
|
-
// 故有回收时重读一次快照——否则下面空闲快进的
|
|
1182
|
+
// 故有回收时重读一次快照——否则下面空闲快进的 stalePool/staleInfo 判定会拿旧快照误判(写盘已归一进锁)。
|
|
1118
1183
|
var reapedN = 0
|
|
1119
1184
|
try { reapedN = await reapGhostRecs(sid, snap) } catch (e) { console.error('[task-board] 幽灵占位回收失败(本轮跳过,派发照常):', String(e)) }
|
|
1120
1185
|
if (reapedN > 0) {
|
|
@@ -1137,8 +1202,20 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1137
1202
|
var stalePool = snap.poolStatus && (((snap.poolStatus.workers || []).length + (snap.poolStatus.verifiers || []).length) > 0)
|
|
1138
1203
|
// dispatchInfo 是瞬时通知(90s TTL):空闲周期也要负责过期清理,否则永久残留
|
|
1139
1204
|
var staleInfo = snap.dispatchInfo && (!snap.dispatchInfoAt || Date.now() - new Date(snap.dispatchInfoAt).getTime() > 90000)
|
|
1140
|
-
if (staleInfo) {
|
|
1141
|
-
|
|
1205
|
+
if (stalePool || staleInfo) {
|
|
1206
|
+
// 锁归一(P1 根修 task-mv1e17x9):空闲清理写盘必须走 mutateLocked,且过期判定在锁内重读重判——
|
|
1207
|
+
// 锁外读到的 stalePool/staleInfo 此刻可能已被并发写清掉,拿旧快照直写会把它覆盖回去(丢更新)。
|
|
1208
|
+
try {
|
|
1209
|
+
await mutateLocked(sid, function (d) {
|
|
1210
|
+
var sp2 = d.poolStatus && (((d.poolStatus.workers || []).length + (d.poolStatus.verifiers || []).length) > 0)
|
|
1211
|
+
var si2 = d.dispatchInfo && (!d.dispatchInfoAt || Date.now() - new Date(d.dispatchInfoAt).getTime() > 90000)
|
|
1212
|
+
if (!sp2 && !si2) return null // 锁内重判已无过期项 → 不写(避免覆盖并发写)
|
|
1213
|
+
if (sp2) d.poolStatus = emptyPool
|
|
1214
|
+
if (si2) { delete d.dispatchInfo; delete d.dispatchInfoAt }
|
|
1215
|
+
return true // 返回非空触发写盘
|
|
1216
|
+
}, true) // skipKick:本轮已是 poolCycle,无需再踢
|
|
1217
|
+
} catch (_) {}
|
|
1218
|
+
}
|
|
1142
1219
|
return snap
|
|
1143
1220
|
}
|
|
1144
1221
|
var c = cfg(snap)
|
|
@@ -1374,5 +1451,5 @@ export function createDispatch(ctx, state, deps) {
|
|
|
1374
1451
|
// 在并行 spawn 下存在认领错包的潜伏 bug,一并消灭。
|
|
1375
1452
|
}
|
|
1376
1453
|
|
|
1377
|
-
return { poolCycle: poolCycle, spawnOneShot: spawnOneShot, accumulateRunUsage: accumulateRunUsage, settleReportedRun: settleReportedRun, readContextPack: readContextPack }
|
|
1454
|
+
return { poolCycle: poolCycle, spawnOneShot: spawnOneShot, accumulateRunUsage: accumulateRunUsage, settleReportedRun: settleReportedRun, readContextPack: readContextPack, closeRunHistory: closeRunHistory }
|
|
1378
1455
|
}
|
package/lib/rpc.mjs
CHANGED
|
@@ -13,7 +13,8 @@ import { TASK_SIZE_CONTRACT, withSplitHint, pushRejectLesson, pushArbitrationLes
|
|
|
13
13
|
import { makeMsg } from './notify.mjs'
|
|
14
14
|
import { computeHealthHints, computeRuntimeHealthHints } from './health.mjs'
|
|
15
15
|
import { isFullSessionId } from './session.mjs' // 幻影板防线口径(纯函数,与 policy.mjs 直引同例)
|
|
16
|
-
|
|
16
|
+
import { previewCleanup, runCleanup, SKIP_REASON_TEXT } from './cleanup.mjs' // 已结算会话清理(预览+执行)
|
|
17
|
+
const { ah, isb, gsb, gpt, vt, validateDeps, classifyPipeline, cfg, claimCheck, claimApply, resolveApply, verifyApply, lateVerdictApply, archiveApply, cancelApply, maybeAutoCloseParent, PRIO_RANK, touchesConflict, holdsFiles, boardHome, aggregateChildStats, createTaskWarnings, epicPrecheck, epicPrecheckNote, attachContextSuggestions, REJECT_REDISPATCH_HINT, tasksHash, normalizeHooks, mergeHooks, pushRejection, normalizeUserTest, draftLint } = core
|
|
17
18
|
|
|
18
19
|
function defineTool(options) {
|
|
19
20
|
var userExecute = options.execute
|
|
@@ -38,13 +39,51 @@ function defineTool(options) {
|
|
|
38
39
|
export function createRpc(ctx, state, deps) {
|
|
39
40
|
// deps:session/store/notify/dispatch 各模块公共 API(index.mjs 接线注入)
|
|
40
41
|
var getActorId = deps.getActorId, resolveRoot = deps.resolveRoot, toolSessionId = deps.toolSessionId, rpcSessionId = deps.rpcSessionId
|
|
42
|
+
// ===== GUI RPC 操作者回退(task-mv493xjs,巡检次生观察①)=====
|
|
43
|
+
// 面板 HTTP RPC 没有 agent 调用栈(currentInitiator 为空),getActorId() 落 'unknown',
|
|
44
|
+
// 流转轨迹 actor 列就显示 unknown。GUI rpc 助手(kernel.js)必带 sessionId 入参,
|
|
45
|
+
// 这里回退到 rpcSessionId(args)(显式 sessionId 优先,缺失时退化解为 actor 归一值),
|
|
46
|
+
// 历史行 actor 落真实会话身份。工具通道(task_*)有真实 initiator,不走本回退。
|
|
47
|
+
function rpcActor(args) { var a = getActorId(); if (a && a !== 'unknown') return a; return rpcSessionId(args) }
|
|
41
48
|
var rootForSession = deps.rootForSession, deriveWorkMode = deps.deriveWorkMode, runsFor = deps.runsFor
|
|
42
49
|
var rt = deps.rt, mutateLocked = deps.mutateLocked
|
|
43
50
|
// 主窗口消耗(task-muwsol23):测试可注入临时日志根;生产 undefined → findRunLog 回退 ~/.dsh/sessions
|
|
44
51
|
var sessionsRoot = (typeof deps.sessionsRoot === 'string' && deps.sessionsRoot) ? deps.sessionsRoot : undefined
|
|
52
|
+
// ===== 已结算会话清理(task-mv2131cz):路径根 + 活跃/血统保护集 =====
|
|
53
|
+
// 会话日志与 projcache 布局(与 usage.mjs findRunLog 同源):~/.dsh/sessions/<bucket>/<id>/、
|
|
54
|
+
// ~/.dsh/storages/session_projcache/sessions/<id>.json;注册表索引 ~/.dsh/storages/session_projcache.json
|
|
55
|
+
// 是宿主托管(version/seq 结构),**不清理**(残留形态见 README「存储清理」小节)。
|
|
56
|
+
var cleanupRoots = {
|
|
57
|
+
sessions: path.join(boardHome(), 'sessions'),
|
|
58
|
+
projcacheDir: path.join(boardHome(), 'storages', 'session_projcache', 'sessions'),
|
|
59
|
+
}
|
|
60
|
+
// 体积估算主源(task-mv39ag1e):官方 SessionPersistenceSnapshot.sizeBytes——stat(id) 只读元数据
|
|
61
|
+
// (不读事件日志、不取写所有权,preview 零副作用不破),替代逐会话目录递归(消全量预览 ~340ms 同步 IO)。
|
|
62
|
+
// 老宿主/测试桩无 sessionPersistence → statSession=null → cleanup 层回退目录遍历(双轨降级不炸)。
|
|
63
|
+
var cleanupSp = ctx.get && ctx.get('sessionPersistence')
|
|
64
|
+
var statSession = (cleanupSp && typeof cleanupSp.stat === 'function') ? function (id) { return cleanupSp.stat(id) } : null
|
|
65
|
+
// 不可删保护集:本板主会话自身 + 活跃树里所有会话(roots + list)——覆盖「不活跃」与「本会话血统」两闸。
|
|
66
|
+
// 「不活跃」判定维持活跃树/血统口径(task-mv39ag1e 决策留痕):上游官方活性判据是 open(id,'write')
|
|
67
|
+
// 抛 SessionAlreadyOwnedError,但 open 'write' 不抛时即**真拿到单写所有权**(且 handle close 会 drain
|
|
68
|
+
// 落盘)——探活本身有副作用,preview 零副作用红线不允许,故不升级,维持现有活跃树判定。
|
|
69
|
+
function cleanupBlockedIds(sid) {
|
|
70
|
+
var blocked = new Set()
|
|
71
|
+
blocked.add(String(sid)) // 本板主会话自身
|
|
72
|
+
var ag = ctx.agents
|
|
73
|
+
if (ag) {
|
|
74
|
+
try {
|
|
75
|
+
var roots = ag.roots(); if (Array.isArray(roots)) for (var i = 0; i < roots.length; i++) blocked.add(String(roots[i].id))
|
|
76
|
+
var all = ag.list(); if (Array.isArray(all)) for (var j = 0; j < all.length; j++) blocked.add(String(all[j].id))
|
|
77
|
+
} catch (_) {}
|
|
78
|
+
}
|
|
79
|
+
return blocked
|
|
80
|
+
}
|
|
45
81
|
var maybeNotify = deps.maybeNotify, notifyTaskDone = deps.notifyTaskDone
|
|
46
82
|
var notifyLateReject = (typeof deps.notifyLateReject === 'function') ? deps.notifyLateReject : null // 迟到驳回 err 级通知(未接线 → 静默跳过)
|
|
47
83
|
var spawnOneShot = deps.spawnOneShot, accumulateRunUsage = deps.accumulateRunUsage, readContextPack = deps.readContextPack
|
|
84
|
+
// 手动终止的 run 关账(outcome:'terminated',task-mv1pqy27 死结窗口根修):与状态无关,
|
|
85
|
+
// 凡 terminate 命中该卡的 run 就关。未注入(老宿主/测试桩)→ 静默跳过,行为与改造前逐字一致。
|
|
86
|
+
var closeRunHistory = (typeof deps.closeRunHistory === 'function') ? deps.closeRunHistory : function () {}
|
|
48
87
|
// 上报通道的 run 收尾(task-muwkhqf8):board_report/board_verdict 推进任务落定后,对当前 continuable
|
|
49
88
|
// Worker rec 补做「关 run 结局 + usage 落账 + 摘超时臂」。未接线(老宿主/测试桩)→ 静默跳过,
|
|
50
89
|
// 行为与本次改造前逐字一致(那时本来就没人做这件事)。
|
|
@@ -183,7 +222,8 @@ export function createRpc(ctx, state, deps) {
|
|
|
183
222
|
ctx.tools.register(defineTool({ name: 'task_resolve', description: '提交验证(verifying)或阻塞(blocked)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, status: { type: 'string', enum: ['verifying', 'blocked'] }, resolution: { type: 'string' } }, required: ['taskId', 'status'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); 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' }; if (t.claimedBy && t.claimedBy !== actor) return { ok: false, error: 'not claimed by you' }; if (args.status === 'verifying' && !args.resolution) return { ok: false, error: 'resolution required' }; return resolveApply(d, t, actor, args.status, args.resolution, args.resolution || args.status) }) } }))
|
|
184
223
|
ctx.tools.register(defineTool({ name: 'task_verify', description: '验收:approved→resolved,rejected→in-progress。子任务全完成父任务自动verifying。Verifier 仍在跑(无 endedAt/run outcome 仍 running)时默认拦截并返回提示;确认提前裁决请加 force:true。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, verdict: { type: 'string', enum: ['approved', 'rejected'] }, comment: { type: 'string' }, force: { type: 'boolean' } }, required: ['taskId', 'verdict'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); 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' }; var __act = activeRunOf(sid, t); if (__act && __act.role === 'verifier' && args.force !== true) return { ok: false, error: 'Verifier 还在跑(run ' + __act.id + '),提前裁决将丢弃它的独立结论。确认提前裁决请加 force:true', verifierActive: __act.id }; var __vr = verifyApply(d, t, actor, args.verdict, args.comment); if (args.verdict === 'rejected') { t.verification = { verdict: 'rejected', summary: args.comment || '', checks: '', at: new Date().toISOString(), by: actor }; pushRejection(t, args.comment, '', t.verification.at, actor); __vr.hint = REJECT_REDISPATCH_HINT } return __vr }) } }))
|
|
185
224
|
ctx.tools.register(defineTool({ name: 'task_archive', description: '归档已解决/已取消任务(无活跃 run 的 in-progress 僵尸卡也可归档,用于 parentKick 僵尸态出清)。', parameters: { type: 'object', properties: { taskId: { type: 'string' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); 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' }; var __ae = archiveErr(sid, t); if (__ae) return { ok: false, error: __ae }; return archiveApply(d, t, actor, 'archived') }) } }))
|
|
186
|
-
ctx.tools.register(defineTool({ name: '
|
|
225
|
+
ctx.tools.register(defineTool({ name: 'task_cancel', description: '取消任务:draft/pending/blocked 态可取消(取消后不再派发,可在归档 tab 恢复待办)。in-progress/verifying 需先 terminate-agent,resolved 请用归档。reason 可选,落流转轨迹。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, reason: { type: 'string', description: '取消理由(可选,随流转轨迹留档)' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doCancel(toolSessionId(), getActorId(), args.taskId, args.reason) } }))
|
|
226
|
+
ctx.tools.register(defineTool({ name: 'task_update', description: '更新任务字段,可选重置为 pending。dependsOn/pipeline 也可更新(环检测会拒绝成环依赖)。contextFiles 可更新预研文件清单。unfreeze:true 解除裁决挂起冻结(frozen)并触发派发。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, assignMode: { type: 'string', enum: ['auto', 'manual'] }, assignee: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(替换式更新),派发时以文件清单(路径:L行号 — 一句用途)随首条 prompt 注入,内容由子代理按需 read 自取' }, contextNotes: { type: 'string', description: '预研笔记(替换式更新):调研结论/原始需求/思路' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, resetToPending: { type: 'boolean' }, unfreeze: { type: 'boolean', description: '解除冻结(frozen)并立即触发派发,用于 hold 裁决补完上下文后重新入池' }, publish: { type: 'boolean', description: '发布草稿为 pending(仅 draft 状态有效)' }, hooks: { type: 'object', additionalProperties: true, description: '史诗 hook 点位(仅主窗口可设):{ pre?: { enabled?, prompt }, post?: { enabled?, prompt } } —— hook 点=一次真实 agent 运行(不是声明式命令):pre 在子任务派发前跑一遍前置准备(未完成前该 epic 的子任务一张都不派),post 在全部子任务了结后跑一遍收口(完成后 epic 才转 verifying)。prompt 是薄框架契约(做什么由 hook agent 判断,吃不准会歧义上报)。传 null 清除,传 { pre: null } 只撤该点位。' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (args.hooks !== undefined && resolveRoot(__ra) !== __ra) return { ok: false, error: 'hooks 仅主窗口可设(子代理无 hooks 权限)' }; if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); var actor = getActorId(); var existsFn = existsInSession(sid); var __res = await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var __pre = null; 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'); /* 调研门禁③:发布的是 epic(有子任务)时轻量预检子任务调研注入,缺失挂返回值由锁外 pushSysNote 汇总 */ __pre = epicPrecheck(d.tasks, t.id, existsFn) } 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; delete t.retryCount; delete t.stuckSince; delete t.cancelledAt; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; if (args.hooks !== undefined) { var __he = applyHooks(t, args.hooks); if (__he.error) return { ok: false, error: __he.error } }; t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res } }))
|
|
187
227
|
ctx.tools.register(defineTool({ name: 'task_create', description: '创建新任务到当前会话看板。acceptance 可选:硬性验收脚本命令(如 "node --test src/x.test.js"),Worker 必须实际运行、Verifier 必须独立复跑。dependsOn 可选:依赖任务 id 数组,依赖全部完成后才会被派发。pipeline 可选:full(默认,工作+验证)/work(只做不验)/direct(不进池,主窗口直接处理)。contextFiles 可选:你在调研中已经读过的关键文件路径数组——派发时以「文件清单」(每行「路径:L起-L止 — 一句用途」)随首条 prompt 一次性注入,只给路径与行号、不含内容本体(子代理用 read 工具按行号范围按需自取:执行时盘面更新鲜,也免了大文件正文撑爆 prompt 与随快照每轮重发),避免子代理从零重复调研。contextNotes 可选:调研结论/原始需求/思路等非文件类上下文,全文随首条 prompt 一次性注入(≤8000 字符)。【开发类任务(涉及代码改动/修复/特性)务必带文件调研】:把调研中读过的关键文件路径放进 contextFiles、结论思路放进 contextNotes——实测可让 Worker 省去 10~15 分钟从零 grep 定位的时间,且方向感天壤之别(无调研的 Worker 只能靠标题猜需求、极易跑偏)。调研门禁:pipeline=full/work 且 touches 非空而未带调研上下文时,返回将附 warning 提示。touches 可选:本任务将要改动的文件路径/glob 数组(如 "src/x.mjs"、"src/**")——派发器在同一时刻只派发 touches 不冲突的任务,避免并行 Worker 改同一批文件互踩;任务持有文件锁(in-progress/verifying 持有,状态流转到 resolved/cancelled/archived 即释放——锁只护正在写的阶段,验收后-提交前的窗口期由主窗口提交纪律 + 史诗 post-hook 承接)。' + TASK_SIZE_CONTRACT, parameters: { type: 'object', properties: { id: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] }, tags: { type: 'array', items: { type: 'string' } }, parentId: { type: 'string' }, instructions: { type: 'string' }, acceptance: { type: 'string' }, dependsOn: { type: 'array', items: { type: 'string' } }, contextFiles: { type: 'array', items: { type: 'string' }, description: '预研文件路径(相对 workspace 或绝对路径;可写「路径:L1-L2 — 一句用途」),派发时以文件清单随首条 prompt 注入(只给路径行号,内容由子代理按需 read 自取)' }, contextNotes: { type: 'string', description: '预研笔记:调研结论/原始需求/思路等(≤8000 字符),全文随首条 prompt 一次性注入' }, pipeline: { type: 'string', enum: ['full', 'work', 'direct'] }, touches: { type: 'array', items: { type: 'string' }, description: '本任务将改动的文件路径/glob(文件级排他锁):与活动任务 touches 冲突时不派发,等锁释放;支持 "src/**" 目录、"./a/b.mjs"、裸文件名等写法' }, draft: { type: 'boolean', description: '创建为草稿(不派发)。Team 模式下缺省即为 true——补全 dependsOn/上下文后用 task_update publish=true 统一发布;非 Team 模式缺省 false,显式 draft:false 可跳过草稿' }, hooks: { type: 'object', additionalProperties: true, description: '史诗 hook 点位(仅主窗口可设):{ pre?: { enabled?, prompt }, post?: { enabled?, prompt } } —— hook 点=一次真实 agent 运行(不是声明式命令):pre 在子任务派发前跑一遍前置准备(未完成前该 epic 的子任务一张都不派),post 在全部子任务了结后跑一遍收口(完成后 epic 才转 verifying)。prompt 是薄框架契约(做什么由 hook agent 判断,吃不准会歧义上报)。传 null 清除,传 { pre: null } 只撤该点位。' } }, required: ['title'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (args.hooks !== undefined && resolveRoot(__ra) !== __ra) return { ok: false, error: 'hooks 仅主窗口可设(子代理无 hooks 权限)' }; if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; var sid = toolSessionId(); 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: ' + args.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, 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 (args.hooks !== undefined) { var __hn = normalizeHooks(args.hooks); if (__hn.error) return { ok: false, error: __hn.error }; if (__hn.hooks && Object.keys(__hn.hooks).length) t.hooks = __hn.hooks } if (!t.pipeline) t.pipeline = classifyPipeline(t); t.pipelineAuto = !args.pipeline; d.tasks.push(t); t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); /* 调研门禁 warning 族(与 create-task RPC 同口径,core 纯函数):空描述/无调研上下文/整树 glob 三类软提示合并为一条(;分隔),不拦截创建 */ var __out = withSplitHint({ ok: true, task: t }, t, cfg(d).epicSplit); var __warns = createTaskWarnings(t); attachContextSuggestions(__out, __warns, t.touches, existsInSession(sid)); if (__warns.length) __out.warning = __warns.join(';'); return __out }) } }))
|
|
188
228
|
|
|
189
229
|
// ===== RPC =====
|
|
@@ -193,13 +233,18 @@ export function createRpc(ctx, state, deps) {
|
|
|
193
233
|
// 旧实现 roots() 只含活体会话——休眠根会话(有板有卡)恒 false,且生成开始/结束 agents 树重建有瞬态窗口
|
|
194
234
|
// 返回不含本 sid(假 false),入口分钟级消失。改读持久 parentSession(见 isRootPersistent):
|
|
195
235
|
// 主源 sessionPersistence.stat(sid)(持久表,休眠会话也读得到)→ header.parentSession 缺省 = 根;
|
|
236
|
+
// fork/seed 会话(v3→v4 迁移产物:parentSession 有值但 delegationDepth===0)同样认根
|
|
237
|
+
// (task-mv59nn9m:「本体数据平台开发」fork 会话被旧判据误判非根导致看板不可见);
|
|
196
238
|
// 老宿主/测试桩无 sessionPersistence → 回退活跃 agent 的 session.header.parentSession,再回退旧 roots() 成员判定。
|
|
197
239
|
// 防抖仍在客户端:kernel.js applyIsRoot——曾确认 true 的会话需连续 3 次 false(3s 心跳≈9s)才收抽屉;
|
|
198
240
|
// 从未 true 的子代理会话仍即时收起(反馈 n-muuerxv9ijxs / task-muupr8ld)。
|
|
199
241
|
async function isRootPersistent(sid) {
|
|
200
242
|
var sp = ctx.get && ctx.get('sessionPersistence')
|
|
201
243
|
if (sp && typeof sp.stat === 'function') {
|
|
202
|
-
|
|
244
|
+
// 判据改用 delegationDepth(task-mv59nn9m):无父 = 根;有父但 delegationDepth===0 的 fork/seed
|
|
245
|
+
// 会话同样认根;delegationDepth 缺省(v3 时代老会话无此字段)且有 parentSession 时维持非根
|
|
246
|
+
// 保守判定——兼容 v3 时代子代理不误放行。
|
|
247
|
+
try { var snap = await sp.stat(sid); if (snap && snap.header) { var hh = snap.header; return !hh.parentSession || (typeof hh.delegationDepth === 'number' && hh.delegationDepth === 0) } } catch (_) {}
|
|
203
248
|
}
|
|
204
249
|
var ag = ctx.agents
|
|
205
250
|
if (ag) {
|
|
@@ -230,6 +275,9 @@ export function createRpc(ctx, state, deps) {
|
|
|
230
275
|
d.epicSplit = cfg(d).epicSplit
|
|
231
276
|
// 自测指南开关(设置区「验收」小节)同法透出:缺字段=开,只有显式 false 才关
|
|
232
277
|
d.verifyUserGuide = cfg(d).verifyUserGuide
|
|
278
|
+
// 清理保留天数(设置区「存储清理」小节):cfg 归一(缺字段/脏值=7,钳 1-90)后透出确定数值,
|
|
279
|
+
// 客户端设置区天数输入与 StorageCleanup 文案都读这一个字段(口径单一,不各自猜缺省)。
|
|
280
|
+
d.cleanupRetentionDays = cfg(d).cleanupRetentionDays
|
|
233
281
|
// 工作模式派生字段:UI 只读这一个字段决定三档选中态(不落盘,写入仍走 boardMode/teamMode)
|
|
234
282
|
d.workMode = deriveWorkMode(d)
|
|
235
283
|
// ===== board 级 token 消耗聚合(现算,不落盘):总量 + 输入/输出/缓存读 + 按模型 + 任务 Top8 =====
|
|
@@ -286,8 +334,21 @@ export function createRpc(ctx, state, deps) {
|
|
|
286
334
|
if (__rkt && __rhints[__rkt.id]) __rkt.reviewHint = __rhints[__rkt.id]
|
|
287
335
|
}
|
|
288
336
|
// 渲染变更检测(反馈 n-mut9rzs2mkhg):tasks 关键字段的稳定 hash(纯函数,口径见 core.tasksHash),
|
|
289
|
-
// 客户端 3s 轮询 hash 相同则跳过 state.tasks 赋值 + notify
|
|
337
|
+
// 客户端 3s 轮询 hash 相同则跳过 state.tasks 赋值 + notify
|
|
290
338
|
d.tasksHash = tasksHash(d.tasks)
|
|
339
|
+
// ===== 条件请求瘦身(task-mv492for,巡检实证 n-mv2turd1o3iv):轮询载荷 1.34MB→KB 级 =====
|
|
340
|
+
// 客户端轮询带上轮 tasksHash 作 args.ifHash:一致说明任务列表实质没变——reviewHint 挂在卡上
|
|
341
|
+
// 已进 hash 序列化口径;childStats 纯由 tasks 派生(输入 status/parentId 已覆盖);usageSummary/
|
|
342
|
+
// healthHints/poolStatus/childStats/各开关是 KB 级聚合,unchanged 分支照常现算返回(上方赋值链不动)。
|
|
343
|
+
// 响应只剔 tasks 全量(description/context/runs/deliverable 全文——1.34MB 大头全在这),
|
|
344
|
+
// 客户端收 unchanged:true 保留 state.tasks 不动(见 kernel.js fetchTasks 的 unchanged 分支)。
|
|
345
|
+
// 不带 ifHash 的老客户端与 includeArchived(归档 tab 数据源 fetchArchived)一律走原有全量分支,零回归。
|
|
346
|
+
if (args && args.ifHash && args.ifHash === d.tasksHash && !args.includeArchived) {
|
|
347
|
+
var __light = Object.assign({}, d)
|
|
348
|
+
delete __light.tasks
|
|
349
|
+
__light.unchanged = true
|
|
350
|
+
return __light
|
|
351
|
+
}
|
|
291
352
|
return d })
|
|
292
353
|
// 派发前上下文预览:主 agent 用它确认"我将注入给子代理的瘦身清单"是否足够(不发任务、不落盘)
|
|
293
354
|
handle('preview-context', async function (args) {
|
|
@@ -367,10 +428,10 @@ export function createRpc(ctx, state, deps) {
|
|
|
367
428
|
out.sort(function (a, b) { return (b.lastActivity || '').localeCompare(a.lastActivity || '') })
|
|
368
429
|
return { ok: true, boards: out }
|
|
369
430
|
})
|
|
370
|
-
handle('claim-task', async function (args) { var sid = rpcSessionId(args); var actor =
|
|
371
|
-
handle('resolve-task', async function (args) { var sid = rpcSessionId(args); var actor =
|
|
372
|
-
handle('verify-task', async function (args) { var sid = rpcSessionId(args); var actor =
|
|
373
|
-
handle('archive-task', async function (args) { var sid = rpcSessionId(args); var actor =
|
|
431
|
+
handle('claim-task', async function (args) { var sid = rpcSessionId(args); var actor = rpcActor(args); 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' }; var err = claimCheck(d, t, actor); if (err) return { ok: false, error: err }; claimApply(d, t, actor, 'manual claim via board'); return { ok: true, task: t } }) })
|
|
432
|
+
handle('resolve-task', async function (args) { var sid = rpcSessionId(args); var actor = rpcActor(args); 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) }) })
|
|
433
|
+
handle('verify-task', async function (args) { var sid = rpcSessionId(args); var actor = rpcActor(args); 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' }; var __act = activeRunOf(sid, t); if (__act && __act.role === 'verifier' && args.force !== true) return { ok: false, error: 'Verifier 还在跑(run ' + __act.id + '),提前裁决将丢弃它的独立结论。确认提前裁决请加 force:true', verifierActive: __act.id }; delete t.escalation; delete t.verifyRetries; var r = verifyApply(d, t, actor, args.verdict, args.comment); if (args.verdict === 'rejected') { t.verification = { verdict: 'rejected', summary: args.comment || '', checks: '', at: new Date().toISOString(), by: actor }; pushRejection(t, args.comment, '', t.verification.at, actor); pushRejectLesson(d, t, args.comment); r.hint = REJECT_REDISPATCH_HINT } return r }) })
|
|
434
|
+
handle('archive-task', async function (args) { var sid = rpcSessionId(args); var actor = rpcActor(args); 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' }; var __ae = archiveErr(sid, t); if (__ae) return { ok: false, error: __ae }; return archiveApply(d, t, actor, 'manual archive') }) })
|
|
374
435
|
// ===== 已阅落账(异常驱动审视①③):打开详情页即幂等落 reviewedAt(唯一新落库字段)=====
|
|
375
436
|
// 幂等只写一次:已有 reviewedAt 就原样返回、绝不重写(时间戳不漂移、history 不刷屏);
|
|
376
437
|
// 后续重复打开详情页零副作用。reviewedAt 进 tasksHash 序列化口径 → 落账后下一轮 get-tasks hash 变化,
|
|
@@ -399,7 +460,7 @@ export function createRpc(ctx, state, deps) {
|
|
|
399
460
|
// 单任务删除:真删(从 d.tasks 数组移除),不写 history(记录随任务一起消失),不留档、无 undo。
|
|
400
461
|
// console.error 留一行操作日志,便于事后溯源"某个卡片什么时候被谁删了"。
|
|
401
462
|
handle('delete-task', async function (args) {
|
|
402
|
-
var sid = rpcSessionId(args); var actor =
|
|
463
|
+
var sid = rpcSessionId(args); var actor = rpcActor(args)
|
|
403
464
|
if (!args.taskId) return { ok: false, error: 'taskId required' }
|
|
404
465
|
return mutateLocked(sid, function (d) {
|
|
405
466
|
var t = d.tasks.find(function (x) { return x.id === args.taskId })
|
|
@@ -412,7 +473,39 @@ export function createRpc(ctx, state, deps) {
|
|
|
412
473
|
return { ok: true, deleted: t.id }
|
|
413
474
|
})
|
|
414
475
|
})
|
|
415
|
-
|
|
476
|
+
// ===== 任务取消通道(task_cancel 工具与 cancel-task RPC 共享,单一行为口径)=====
|
|
477
|
+
// 背景:主窗口作废一张 pending/draft/blocked 卡无正规通道——archive 拒收、update 无 status、
|
|
478
|
+
// delete 是真删(丢留档)。取消=显式放弃:置 cancelled + 留档可检索、可在归档 tab 恢复待办。
|
|
479
|
+
// 状态门禁(与 deleteGate 同居「门禁在接线层」分工):
|
|
480
|
+
// draft / pending / blocked → 允许(未产生执行痕迹)
|
|
481
|
+
// in-progress 且 claimedBy=spawn-pending → 允许(派发瞬时占位,无真实 run,cancelApply 内回退占位)
|
|
482
|
+
// in-progress(真实 run)/ verifying → 拒绝,提示先 terminate-agent 终止
|
|
483
|
+
// resolved → 拒绝,引导用 archive-task(已落定任务留档可检索)
|
|
484
|
+
// cancelled / archived → 幂等 ok(已在取消/归档态)
|
|
485
|
+
// 返回 null(放行)或 { err }(拒绝)或 { mode: 'already' }(幂等)。
|
|
486
|
+
function cancelGate(t) {
|
|
487
|
+
if (t.status === 'in-progress' && t.claimedBy === 'spawn-pending') return null
|
|
488
|
+
if (t.status === 'in-progress' || t.status === 'verifying') return { err: '任务正在执行中,请先用 terminate-agent 终止(in-progress 回待办、verifying 换 verifier 接手)再取消' }
|
|
489
|
+
if (t.status === 'resolved') return { err: '已落定任务请用归档(archive-task),不要取消' }
|
|
490
|
+
if (t.status === 'cancelled' || t.status === 'archived') return { mode: 'already' }
|
|
491
|
+
if (t.status !== 'draft' && t.status !== 'pending' && t.status !== 'blocked') return { err: '任务状态 ' + t.status + ' 不在可取消范围(仅草稿/待办/阻塞可取消)' }
|
|
492
|
+
return null
|
|
493
|
+
}
|
|
494
|
+
async function doCancel(sid, actor, taskId, reason) {
|
|
495
|
+
return mutateLocked(sid, function (d) {
|
|
496
|
+
var t = d.tasks.find(function (x) { return x.id === taskId })
|
|
497
|
+
if (!t) return { ok: false, error: 'not found' }
|
|
498
|
+
var g = cancelGate(t)
|
|
499
|
+
if (g) {
|
|
500
|
+
if (g.mode === 'already') return { ok: true, task: t, alreadyCancelled: true }
|
|
501
|
+
return { ok: false, error: g.err }
|
|
502
|
+
}
|
|
503
|
+
var note = (reason && String(reason).trim()) ? ('取消: ' + String(reason).trim().slice(0, 200)) : '取消'
|
|
504
|
+
return cancelApply(d, t, actor, note)
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
handle('cancel-task', async function (args) { return doCancel(rpcSessionId(args), rpcActor(args), args.taskId, args.reason) })
|
|
508
|
+
handle('update-task', async function (args) { var sid = rpcSessionId(args); var actor = rpcActor(args); if (args.hooks !== undefined && hooksDenied(actor)) return { ok: false, error: HOOKS_MAIN_ONLY }; var existsFn = existsInSession(sid); var __res = await mutateLocked(sid, function (d) { var t = d.tasks.find(function (x) { return x.id === args.taskId }); if (!t) return { ok: false, error: 'not found' }; var __pre = null; 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.tags !== undefined) t.tags = Array.isArray(args.tags) ? args.tags.map(function (s) { return String(s).trim() }).filter(function (s) { return s.length > 0 }) : []; 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'); /* 调研门禁③:发布的是 epic(有子任务)时轻量预检子任务调研注入,缺失挂返回值由锁外 pushSysNote 汇总 */ __pre = epicPrecheck(d.tasks, t.id, existsFn) } 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; delete t.retryCount; delete t.stuckSince; delete t.cancelledAt; ah(t, ps, 'pending', actor, 'reset to pending after edit') }; if (args.hooks !== undefined) { var __he = applyHooks(t, args.hooks); if (__he.error) return { ok: false, error: __he.error } }; t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); var __out = { ok: true, task: t }; if (__pre && __pre.missing.length) __out.epicPrecheck = __pre; return __out }); afterPublishPrecheck(sid, args.taskId, __res); return __res })
|
|
416
509
|
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) } }) })
|
|
417
510
|
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) } }) })
|
|
418
511
|
// 工作模式三档单入口(v75 UI 收敛):一次写入 boardMode+teamMode 两个字段,
|
|
@@ -471,7 +564,7 @@ export function createRpc(ctx, state, deps) {
|
|
|
471
564
|
return { ok: true, task: t, unfrozen: true }
|
|
472
565
|
})
|
|
473
566
|
}
|
|
474
|
-
handle('unfreeze-task', async function (args) { return doUnfreezeTask(rpcSessionId(args),
|
|
567
|
+
handle('unfreeze-task', async function (args) { return doUnfreezeTask(rpcSessionId(args), rpcActor(args), args.taskId) })
|
|
475
568
|
// 高优介入(v74):有活跃 run 则直接打进其会话;无则只记录 history(下次派发随 prompt 注入)
|
|
476
569
|
// 通道选择(v1.2.4):steer 优先——下一个 step 边界即消费;followup 要等整个 turn 结束,
|
|
477
570
|
// 长 turn 下干预形同失联(实测:Worker 单 turn 跑 10+ 分钟,「口径重写」类干预到位时活已按旧口径干完)。
|
|
@@ -559,8 +652,6 @@ export function createRpc(ctx, state, deps) {
|
|
|
559
652
|
// 清理:一次性 run 走 dispose(既有语义逐字不变);continuable 的 rec 没有 run(持久子会话),
|
|
560
653
|
// 卡2 起改走 **interrupt 留存**(task-muw5h2ps):只打断当前 turn、不销毁子会话——否则「手动终止」
|
|
561
654
|
// 之后旧 Worker 仍在改同一棵树,与新派发的 Worker 并行写盘(终止按钮反而制造双写)。
|
|
562
|
-
// 手动终止**刻意不留续跑资格**:run 结局停在 running(不 closeRunHistory)→ 重派走 fresh spawn,
|
|
563
|
-
// 不把用户刚终止的子会话又唤醒(终止语义 = 重新排队,不是续跑)。
|
|
564
655
|
// interrupt 失败(会话已死 / 无 root 可授权)静默跳过:任务照常回 pending 重排,不卡死。
|
|
565
656
|
try { if (rec.run) await rec.run.dispose() } catch (_) {}
|
|
566
657
|
if (rec.continuable) {
|
|
@@ -572,16 +663,26 @@ export function createRpc(ctx, state, deps) {
|
|
|
572
663
|
// 手动终止的 run 不会走 settleRun(runsFor 已摘除,结算路径会早退),
|
|
573
664
|
// 但它确实消耗了 token——在这里补一次结算,避免终止即丢账。
|
|
574
665
|
await accumulateRunUsage(sid, rec)
|
|
666
|
+
// 关 run 结局 outcome:'terminated'(与状态无关,task-mv1pqy27 死结窗口根修):
|
|
667
|
+
// terminate 命中该卡的 run 就关账,否则卡面 runs 留档停在 running 且无 endedAt,
|
|
668
|
+
// activeRunOf 的卡面兜底会把它误判为活跃 → archive 永久拒(~50ms 结算窗口)。
|
|
669
|
+
// terminated 不满足 resumeTarget 的续跑判据(只认 timeout/error|incomplete)→ 仍不留续跑资格。
|
|
670
|
+
try { await closeRunHistory(sid, taskId, String(rec.id), 'terminated') } catch (_) {}
|
|
575
671
|
}
|
|
576
672
|
await mutateLocked(sid, function (d) {
|
|
577
673
|
var t = d.tasks.find(function (x) { return x.id === taskId })
|
|
578
674
|
if (!t) return null
|
|
579
675
|
delete t.stuckSince
|
|
676
|
+
// 认领位清理与状态无关(task-mv1pqy27 死结窗口根修):凡 terminate 命中该卡的 run(rec 在内存表),
|
|
677
|
+
// 按 rec.role 清对应认领位——覆盖 resolved/archived/cancelled 态(卡已落定但 run 尚在结算窗口)的
|
|
678
|
+
// 死结:否则 verifierRun/claimedBy 仍指向已终止 run 的 running 留档 → activeRunOf 卡面兜底误判活跃 → archive 永久拒。
|
|
679
|
+
if (rec) {
|
|
680
|
+
if (rec.role === 'verifier') { t.verifierRun = null; delete t.verifierRunAt }
|
|
681
|
+
else { t.claimedBy = null; t.claimedAt = null }
|
|
682
|
+
}
|
|
580
683
|
if (t.status === 'in-progress') { var ps = t.status; t.status = 'pending'; t.claimedBy = null; t.claimedAt = null; ah(t, ps, 'pending', actor, '手动终止,任务重新排队') }
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
// activeRunOf 的卡面兜底(liveEntry 按 verifierRun 匹配)会把已死的 run 误判为活跃 → archive 门禁
|
|
584
|
-
// 永久拒绝,而报错文案推荐的补救「先 terminate」再跑一次也是空转(内存表已摘、认领位不清)= 死结。
|
|
684
|
+
// 无活跃 rec 的 stale 卡(重启/已摘除)也要按状态兜底清认领位:in-progress 清 claimedBy 同口径,
|
|
685
|
+
// 否则卡面 verifierRun 仍指向已死 run 的 running 留档 → archive 永久拒(rec 不存在时 rec.role 无从判定)。
|
|
585
686
|
else if (t.status === 'verifying') { t.verifierRun = null; delete t.verifierRunAt; ah(t, 'verifying', 'verifying', actor, '手动终止审查,等待新 verifier 接手') }
|
|
586
687
|
return t
|
|
587
688
|
})
|
|
@@ -596,8 +697,8 @@ export function createRpc(ctx, state, deps) {
|
|
|
596
697
|
})
|
|
597
698
|
return { ok: true }
|
|
598
699
|
}
|
|
599
|
-
handle('terminate-agent', async function (args) { return doTerminate(rpcSessionId(args),
|
|
600
|
-
handle('dismiss-suspect', async function (args) { return doDismiss(rpcSessionId(args),
|
|
700
|
+
handle('terminate-agent', async function (args) { return doTerminate(rpcSessionId(args), rpcActor(args), args.taskId) })
|
|
701
|
+
handle('dismiss-suspect', async function (args) { return doDismiss(rpcSessionId(args), rpcActor(args), args.taskId) })
|
|
601
702
|
ctx.tools.register(defineTool({ name: 'task_terminate', description: 'Team 模式:终止执行某任务的池中 Agent(卡死/跑偏时)。in-progress 任务回 pending 重派,verifying 由新 verifier 接手。', parameters: { type: 'object', properties: { taskId: { type: 'string' } }, required: ['taskId'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doTerminate(toolSessionId(), getActorId(), args.taskId) } }))
|
|
602
703
|
// 手动触发单任务派发(manual 模式下"派发给 Worker"按钮,或 auto 模式手动补派)
|
|
603
704
|
// touches 文件级排他:默认尊重文件锁——候选与活动任务 touches 冲突时返回
|
|
@@ -633,8 +734,8 @@ export function createRpc(ctx, state, deps) {
|
|
|
633
734
|
if (role === 'worker') { await mutateLocked(sid, function (d) { var t2 = d.tasks.find(function (x) { return x.id === args.taskId }); if (t2 && t2.status === 'in-progress' && t2.claimedBy === 'spawn-pending') { t2.status = 'pending'; t2.claimedBy = null; t2.claimedAt = null; ah(t2, 'in-progress', 'pending', 'system', 'spawn 失败') }; return t2 }, true) }
|
|
634
735
|
return { ok: false, error: 'spawn failed' }
|
|
635
736
|
})
|
|
636
|
-
handle('resolve-escalation', async function (args) { return doResolveEscalation(rpcSessionId(args),
|
|
637
|
-
handle('intervene-agent', async function (args) { return doIntervene(rpcSessionId(args),
|
|
737
|
+
handle('resolve-escalation', async function (args) { return doResolveEscalation(rpcSessionId(args), rpcActor(args), args.taskId, args.answer, args.action) })
|
|
738
|
+
handle('intervene-agent', async function (args) { return doIntervene(rpcSessionId(args), rpcActor(args), args.taskId, args.message) })
|
|
638
739
|
// 主 Agent 工具版(Team 模式下主 Agent 通过工具裁决/介入)
|
|
639
740
|
ctx.tools.register(defineTool({ name: 'task_arbitrate', description: 'Team 模式:裁决 Worker 上报的歧义(escalation)。action=resume(默认)裁决后任务回派发池立即重派;action=hold 任务挂起冻结、不参与自动派发(留给主窗口补 dependsOn/上下文,补完后用 task_update unfreeze:true 解冻)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, answer: { type: 'string' }, action: { type: 'string', enum: ['resume', 'hold'], description: 'resume(默认)=裁决后重新派发;hold=挂起冻结不派发' } }, required: ['taskId', 'answer'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doResolveEscalation(toolSessionId(), getActorId(), args.taskId, args.answer, args.action) } }))
|
|
640
741
|
ctx.tools.register(defineTool({ name: 'task_intervene', description: 'Team 模式:向执行某任务的池中 Agent 发起高优先级指令(steer 通道,当前 step 结束即响应;无活跃 run 时记录随下次派发注入)。', parameters: { type: 'object', properties: { taskId: { type: 'string' }, message: { type: 'string' } }, required: ['taskId', 'message'] }, output: jo(), execute: async function (args) { var __ra = getActorId(); if (resolveRoot(__ra) !== __ra) return { ok: false, error: '看板管理工具仅主窗口可用(子代理无看板权限)' }; return doIntervene(toolSessionId(), getActorId(), args.taskId, args.message) } }))
|
|
@@ -769,15 +870,15 @@ export function createRpc(ctx, state, deps) {
|
|
|
769
870
|
// 史诗拆分总开关 epicSplit(UI「功能」小节):布尔原样落盘 + **当场回填 epicSplitCache**——Team 提示词
|
|
770
871
|
// 组装是同步函数只能读缓存,不回填就得等下一次 rt() 读盘才生效(关掉后仍按旧的劝拆一轮,体感是开关没生效)。
|
|
771
872
|
// 与 feedbackEnabled 同款双写;机制面(parentId 建子卡/自动收口/hooks)不看这个键。
|
|
772
|
-
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 } else if (args.key === 'notifyDispatch') d.notifyDispatch = !!args.value; else if (args.key === 'notifyDone') d.notifyDone = !!args.value; else if (args.key === 'workerContinuable') d.workerContinuable = !!args.value; else if (args.key === 'verifyUserGuide') d.verifyUserGuide = !!args.value; else if (args.key === 'epicSplit') { d.epicSplit = !!args.value; epicSplitCache[sid] = d.epicSplit } return { ok: true } }) })
|
|
773
|
-
handle('create-task', async function (args) { var sid = rpcSessionId(args); /* 短 id 防幻影板(task-muuf0o7a):截断/不完整 sessionId 建出的板永远匹配不到活 root(每 15s 刷屏元凶),显式报错优于静默建幻影板;import- 前缀板由 isFullSessionId 放行 */ if (!isFullSessionId(sid)) return { ok: false, error: 'sessionId 不完整(疑似截断短 id),拒绝创建任务: ' + sid }; var actor =
|
|
873
|
+
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 === 'cleanupRetentionDays') d.cleanupRetentionDays = Math.max(1, Math.min(90, Number(args.value) || 7)); else if (args.key === 'feedbackEnabled') { d.feedbackEnabled = !!args.value; feedbackCache[sid] = d.feedbackEnabled } else if (args.key === 'notifyDispatch') d.notifyDispatch = !!args.value; else if (args.key === 'notifyDone') d.notifyDone = !!args.value; else if (args.key === 'workerContinuable') d.workerContinuable = !!args.value; else if (args.key === 'verifyUserGuide') d.verifyUserGuide = !!args.value; else if (args.key === 'epicSplit') { d.epicSplit = !!args.value; epicSplitCache[sid] = d.epicSplit } return { ok: true } }) })
|
|
874
|
+
handle('create-task', async function (args) { var sid = rpcSessionId(args); /* 短 id 防幻影板(task-muuf0o7a):截断/不完整 sessionId 建出的板永远匹配不到活 root(每 15s 刷屏元凶),显式报错优于静默建幻影板;import- 前缀板由 isFullSessionId 放行 */ if (!isFullSessionId(sid)) return { ok: false, error: 'sessionId 不完整(疑似截断短 id),拒绝创建任务: ' + sid }; var actor = rpcActor(args); /* hooks 只许主窗口设置(角色门禁) */ if (args.hooks !== undefined && hooksDenied(actor)) return { ok: false, error: HOOKS_MAIN_ONLY }; return mutateLocked(sid, function (d) { var nHooks = null; if (args.hooks !== undefined) { var hn = normalizeHooks(args.hooks); if (hn.error) return { ok: false, error: hn.error }; nHooks = hn.hooks } 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 (nHooks && Object.keys(nHooks).length) t.hooks = nHooks; if (!t.pipeline) { t.pipeline = classifyPipeline(t); t.pipelineAuto = true }; d.tasks.push(t); t.lintWarnings = draftLint(t, draftLintIo(sid, d.ownerCwd)); /* 调研门禁 warning 族(与并行 UI 卡约定字段名 warning,core 纯函数统一口径):空描述/无调研上下文/整树 glob 三类软提示合并为一条(;分隔),不拦截创建;字段可选,老 client 无感 */ var out = withSplitHint({ ok: true, task: t }, t, cfg(d).epicSplit); var warns = createTaskWarnings(t); attachContextSuggestions(out, warns, t.touches, existsInSession(sid)); if (warns.length) out.warning = warns.join(';'); return out }) })
|
|
774
875
|
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) } } })
|
|
775
876
|
// ===== #14 批量操作:archive(仅 resolved/cancelled)/ set-priority(全部)/ delete(真删,无 undo)=====
|
|
776
877
|
// delete op 与单任务 delete-task 走同一套 deleteGate 门禁(状态 + 未归档子任务),
|
|
777
878
|
// done/skipped 语义与 archive 完全一致:门禁不过就进 skipped(附 reasons[id] 一行原因)。
|
|
778
879
|
// 注意:批量删除是**真删**(从 tasks 数组移除),不产生 undo 快照——batch-undo 对 delete 无意义。
|
|
779
880
|
handle('batch-op', async function (args) {
|
|
780
|
-
var sid = rpcSessionId(args); var actor =
|
|
881
|
+
var sid = rpcSessionId(args); var actor = rpcActor(args)
|
|
781
882
|
var ids = Array.isArray(args.ids) ? args.ids : []
|
|
782
883
|
if (ids.length === 0) return { ok: false, error: 'no ids' }
|
|
783
884
|
return mutateLocked(sid, function (d) {
|
|
@@ -828,7 +929,7 @@ export function createRpc(ctx, state, deps) {
|
|
|
828
929
|
// 客户端也不为 delete 显示「↩️ 撤销」按钮(snapshot 只在 done>0 且 op!=='delete' 时保留)。
|
|
829
930
|
// 若老客户端硬发 op='delete' 快照进来,落到 else 分支只做 priority 回填,不会凭空复活任务。
|
|
830
931
|
handle('batch-undo', async function (args) {
|
|
831
|
-
var sid = rpcSessionId(args); var actor =
|
|
932
|
+
var sid = rpcSessionId(args); var actor = rpcActor(args)
|
|
832
933
|
var snap = args && args.snapshot
|
|
833
934
|
if (!snap || !Array.isArray(snap.items) || snap.items.length === 0) return { ok: false, error: 'no snapshot' }
|
|
834
935
|
return mutateLocked(sid, function (d) {
|
|
@@ -849,6 +950,34 @@ export function createRpc(ctx, state, deps) {
|
|
|
849
950
|
})
|
|
850
951
|
})
|
|
851
952
|
|
|
953
|
+
// ===== 已结算会话清理(task-mv2131cz):cleanup-preview / cleanup-run =====
|
|
954
|
+
// preview:dry-run,返回可删清单(N 个 / 总大小 / 按卡分组 / 跳过项与理由),只读盘估体积零副作用。
|
|
955
|
+
// run:confirm 执行真删(会话目录 + projcache json),删前再验路径逃逸;操作写 board.cleanupLog 留痕。
|
|
956
|
+
handle('cleanup-preview', async function (args) {
|
|
957
|
+
var sid = rpcSessionId(args)
|
|
958
|
+
var d = await rt(sid)
|
|
959
|
+
var view = await previewCleanup(d.tasks || [], { blockedIds: cleanupBlockedIds(sid), roots: cleanupRoots, retentionDays: cfg(d).cleanupRetentionDays, statSession: statSession })
|
|
960
|
+
var skipped = (view.skipped || []).map(function (s) { return { id: s.id, taskId: s.taskId, reason: s.reason, text: s.text || SKIP_REASON_TEXT[s.reason] || s.reason } })
|
|
961
|
+
return { ok: true, total: view.total, totalBytes: view.totalBytes, items: view.items, skipped: skipped, byTask: view.byTask }
|
|
962
|
+
})
|
|
963
|
+
handle('cleanup-run', async function (args) {
|
|
964
|
+
var sid = rpcSessionId(args)
|
|
965
|
+
if (args.confirm !== true) return { ok: false, error: '删除不可恢复,需 confirm:true 确认(token 账已入卡不受影响)' }
|
|
966
|
+
var d = await rt(sid)
|
|
967
|
+
var view = await previewCleanup(d.tasks || [], { blockedIds: cleanupBlockedIds(sid), roots: cleanupRoots, retentionDays: cfg(d).cleanupRetentionDays, statSession: statSession })
|
|
968
|
+
if (!view.items || !view.items.length) return { ok: true, deleted: 0, freedBytes: 0, note: '无可删会话' }
|
|
969
|
+
var report = await runCleanup(view.items, { roots: cleanupRoots, statSession: statSession })
|
|
970
|
+
// 留痕:board.cleanupLog 追加一条(不逐卡写 history 防刷屏;见 README「存储清理」小节)
|
|
971
|
+
await mutateLocked(sid, function (dd) {
|
|
972
|
+
if (!Array.isArray(dd.cleanupLog)) dd.cleanupLog = []
|
|
973
|
+
dd.cleanupLog.push({ at: new Date().toISOString(), by: rpcActor(args), deleted: report.deleted, freedBytes: report.freedBytes, ids: report.deletedIds })
|
|
974
|
+
if (dd.cleanupLog.length > 50) dd.cleanupLog = dd.cleanupLog.slice(-50)
|
|
975
|
+
return { ok: true }
|
|
976
|
+
}, true)
|
|
977
|
+
var skippedText = (report.skipped || []).map(function (s) { return { id: s.id, reason: s.reason, text: SKIP_REASON_TEXT[s.reason] || s.reason } })
|
|
978
|
+
return { ok: true, deleted: report.deleted, freedBytes: report.freedBytes, deletedIds: report.deletedIds, skipped: skippedText }
|
|
979
|
+
})
|
|
980
|
+
|
|
852
981
|
// ===== client ↔ host RPC:POST /dsh-agent-board { method, args } → JSON =====
|
|
853
982
|
function readBody(req, limit) {
|
|
854
983
|
return new Promise(function (resolve, reject) {
|