dsh-agent-board 1.7.2 → 1.7.4
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 +31 -13
- package/index.mjs +13 -5
- package/lib/client/dashboard.js +120 -39
- package/lib/client/kernel.js +44 -6
- package/lib/client/task-detail.js +7 -3
- package/lib/client.js +171 -48
- package/lib/core.mjs +61 -29
- package/lib/dispatch.mjs +920 -226
- package/lib/rpc.mjs +127 -18
- package/lib/store.mjs +26 -1
- package/lib/usage.mjs +323 -20
- package/package.json +1 -1
- package/test/core.test.mjs +2066 -107
- package/test/usage-mainwindow.test.mjs +230 -0
package/lib/client.js
CHANGED
|
@@ -297,10 +297,28 @@ function apply(ctx) {
|
|
|
297
297
|
}
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
// ===== 设置开关权威纠偏(反馈:勾选几秒才同步,task-muw5uudk)=====
|
|
301
|
+
// 开关字段(学习反馈/派发回执/完成回执/史诗拆分)在 get-tasks 里**无条件**照常赋值,但取值口径是
|
|
302
|
+
// 「缺字段/脏值=开,只有显式 false 才关」,与 host core.cfg 同口径——单点定义,避免纠偏把脏值当真值。
|
|
303
|
+
function cfgKnobOf(src, key) { return !(src && src[key] === false) }
|
|
304
|
+
// 变更检测:与 notify 同类的轻量浅比较(只看这几个布尔开关;settings 无嵌套对象)。
|
|
305
|
+
function cfgKnobsChanged(a, b) { return a.feedbackEnabled !== b.feedbackEnabled || a.notifyDispatch !== b.notifyDispatch || a.notifyDone !== b.notifyDone || a.epicSplit !== b.epicSplit }
|
|
306
|
+
|
|
300
307
|
function fetchTasks() {
|
|
301
308
|
if (!state.sessionId) return
|
|
302
309
|
var epoch = reqEpoch
|
|
303
|
-
|
|
310
|
+
// ===== 统计范围随轮询带给 host(Token 区的模型分布/Top8/累计要按范围重算)=====
|
|
311
|
+
// 为什么过滤在 host 做:run 级数据(runs[i].usage + runs[i].at)只在 host,客户端只拿聚合,
|
|
312
|
+
// 想按范围裁只能重拉一次 host 聚合——所以范围变化时由 RangeFilter 的 setRange 触发本函数(见 dashboard.js)。
|
|
313
|
+
// 空范围(两端皆空)不传 field:host 收到 undefined 即全量聚合,与老 host 的调用形态完全一致;
|
|
314
|
+
// 绝不用空字符串占位(host 侧 `from`/`to` 空串虽也判为无范围,但少传一个字段就少一处口径分叉)。
|
|
315
|
+
var rgSend = activeRange()
|
|
316
|
+
var rpcArgs = (rgSend.from || rgSend.to) ? { range: rgSend } : undefined
|
|
317
|
+
// 范围守卫:范围已在本轮响应回来前被改掉 → 这份响应属于旧范围,丢弃 usageSummary 赋值
|
|
318
|
+
// (否则旧范围的旧聚合会覆盖新范围的新聚合,用户看到「切了范围数字没变」直到下一次轮询)。
|
|
319
|
+
// 注意 tasksHash 短路只管 tasks:usageSummary 照常赋值(现状已对,保持)。
|
|
320
|
+
var rgKey = rgSend.from + '~' + rgSend.to
|
|
321
|
+
rpc('get-tasks', rpcArgs).then(function (d) {
|
|
304
322
|
if (epoch !== reqEpoch) return // 会话已切换,丢弃过期响应
|
|
305
323
|
if (d && d.error) { reportReadErr('看板数据刷新失败:' + d.error); return } // error 分支:保留旧 tasks/设置,绝不当空板渲染
|
|
306
324
|
clearReadErr()
|
|
@@ -311,6 +329,11 @@ function apply(ctx) {
|
|
|
311
329
|
// 老 host 不返回 tasksHash → 恒视为变化,行为与旧版完全一致。
|
|
312
330
|
var newHash = (d && d.tasksHash) || ''
|
|
313
331
|
var tasksChanged = !newHash || newHash !== state.tasksHash
|
|
332
|
+
// 设置开关的权威纠偏(task-muw5uudk):开关不走 tasksHash,hash 不变分支里照常赋值也没有 notify
|
|
333
|
+
// → 勾选框必须等下一次任意 notify 才翻面(安静板卡数秒)。这里在赋值前快照、赋值后比对,
|
|
334
|
+
// 有变化就补一次 notify:乐观更新(PoolCfgPopover)已让点击瞬时翻面,本兜底是服务端权威值纠偏
|
|
335
|
+
// (乐观值与服务端不一致时以服务端为准,失败回滚亦由此收敛)。
|
|
336
|
+
var cfgKnobs = { feedbackEnabled: state.feedbackEnabled, notifyDispatch: state.notifyDispatch, notifyDone: state.notifyDone, epicSplit: state.epicSplit }
|
|
314
337
|
if (tasksChanged) {
|
|
315
338
|
state.tasksHash = newHash
|
|
316
339
|
state.tasks = (d && d.tasks) || []
|
|
@@ -329,7 +352,18 @@ function apply(ctx) {
|
|
|
329
352
|
state.softTimeoutMin = (d && d.softTimeoutMin) || 30
|
|
330
353
|
state.hardTimeoutMin = (d && d.hardTimeoutMin) || 120
|
|
331
354
|
state.poolStatus = (d && d.poolStatus) || null
|
|
332
|
-
|
|
355
|
+
// token 消耗聚合(board 级,host 端现算):范围守卫——响应回来时范围若已变,保留旧值不覆盖
|
|
356
|
+
// (新范围的那次请求会带着新聚合回来;老 host 不返回该字段 → null,零渲染)
|
|
357
|
+
// ===== usageSummary 变化检测(task-muwq9u04:选范围不重渲染)=====
|
|
358
|
+
// 病根:范围切换只让 state.usageSummary 换对象,tasksHash 与四个 cfg 开关都不变 → 无 notify
|
|
359
|
+
// → Token 区冻在旧数字上,要等下一次任意 notify(改任务/切开关)才翻新。
|
|
360
|
+
// 为什么用「上一次的 JSON 串」比对而不是对象引用:host 每次 get-tasks 都现算聚合、**必然返回新对象**
|
|
361
|
+
// (引用比较恒真)→ 3s 轮询每轮都 notify,tasksHash 的渲染节约当场作废。JSON 串只在这份聚合
|
|
362
|
+
// 真变了时才不等(KB 级体积、3s 一次,代价可忽略);赋值前先快照,赋值后比对。
|
|
363
|
+
var usageJsonPrev = JSON.stringify(state.usageSummary || null)
|
|
364
|
+
var rgNow = activeRange()
|
|
365
|
+
if (rgKey === (rgNow.from + '~' + rgNow.to)) state.usageSummary = (d && d.usageSummary) || null
|
|
366
|
+
var usageDelta = !tasksChanged && JSON.stringify(state.usageSummary || null) !== usageJsonPrev
|
|
333
367
|
// 架构健康提示(架构自省 L1):与 tasks 同源透传,HealthHints 组件直接读 state.healthHints,
|
|
334
368
|
// 不再自持 rpc('get-tasks')——消灭仪表盘打开期间的双轮询。老 host 无此字段 → 空数组零渲染
|
|
335
369
|
state.healthHints = (d && Array.isArray(d.healthHints)) ? d.healthHints : []
|
|
@@ -337,12 +371,16 @@ function apply(ctx) {
|
|
|
337
371
|
// 缺省兼容——老 host 不返回该字段时置空对象,卡片/详情层遇空一律不渲染相关元素
|
|
338
372
|
state.childStats = (d && d.childStats) || {}
|
|
339
373
|
// 学习飞轮 v1 能力检测:老 host 不返回该字段 → 视为开启(默认开);只有显式 false 才关。
|
|
340
|
-
state.feedbackEnabled =
|
|
374
|
+
state.feedbackEnabled = cfgKnobOf(d, 'feedbackEnabled')
|
|
341
375
|
// 回执开关(设置区「通知」):同口径——老 host 不返回 → 视为开,只有显式 false 才关
|
|
342
|
-
state.notifyDispatch =
|
|
343
|
-
state.notifyDone =
|
|
376
|
+
state.notifyDispatch = cfgKnobOf(d, 'notifyDispatch')
|
|
377
|
+
state.notifyDone = cfgKnobOf(d, 'notifyDone')
|
|
344
378
|
// 史诗拆分总开关(设置区「功能」):同上——老 host 不返回 = 开(引导照旧),只有显式 false 才关
|
|
345
|
-
state.epicSplit =
|
|
379
|
+
state.epicSplit = cfgKnobOf(d, 'epicSplit')
|
|
380
|
+
// 开关有变 / Token 区聚合有变 → 补一次 notify(tasksChanged 分支已在上面 notify 过,
|
|
381
|
+
// 这里只管 hash 不变时被跳过的那两次:开关乐观更新纠偏 + 统计范围切换后的新聚合)
|
|
382
|
+
var cfgDelta = !tasksChanged && cfgKnobsChanged(cfgKnobs, state)
|
|
383
|
+
if (cfgDelta || usageDelta) notify()
|
|
346
384
|
applyIsRoot(!d || d.isRoot !== false) // 原始值只喂给防抖器,消费点一律读 isRootStable
|
|
347
385
|
if (!state.isRootStable && state.open) { state.open = false; state.detailId = null } // 子代理会话(含连续 3 次 false 的真降级):强制收起看板
|
|
348
386
|
if (d && d.dispatchInfo && d.dispatchInfoAt && Date.now() - new Date(d.dispatchInfoAt).getTime() < 120000) { state.dispatchInfo = d.dispatchInfo } else { state.dispatchInfo = '' } // 瞬时通知 2min 内有效,过期强制清空(服务端写后不清曾致残留数天)
|
|
@@ -1132,7 +1170,7 @@ function apply(ctx) {
|
|
|
1132
1170
|
if (r && r.ok === false && r.error === 'touches-conflict') {
|
|
1133
1171
|
var ids = Array.isArray(r.conflicts) ? r.conflicts : []
|
|
1134
1172
|
var names = ids.map(function (id) { var t2 = getTask(id); return (t2 ? t2.title : id) + ' (' + id + ')' }).join('、')
|
|
1135
|
-
if (window.confirm('⚠️ 文件锁冲突:以下任务正持有同一批文件(touches
|
|
1173
|
+
if (window.confirm('⚠️ 文件锁冲突:以下任务正持有同一批文件(touches 重叠;in-progress/verifying 卡持锁,状态流转到 resolved/cancelled/归档 即自动放行):\n\n' + (names || ids.join('、')) + '\n\n强行并行可能互相覆盖改动/diff 冲突。仍要越权派发吗?')) return send(true)
|
|
1136
1174
|
setActionMsg('⛔ 已取消派发(等文件锁释放,或调整 touches 声明)')
|
|
1137
1175
|
return { ok: false, cancelled: true }
|
|
1138
1176
|
}
|
|
@@ -1274,11 +1312,15 @@ function apply(ctx) {
|
|
|
1274
1312
|
var seq = r.role === 'verifier' ? (++vN) : (++wN)
|
|
1275
1313
|
var isCur = (r.role === 'worker' && task.claimedBy === r.id) || (r.role === 'verifier' && task.verifierRun === r.id)
|
|
1276
1314
|
var okMark = r.outcome === 'completed' ? ' ✅' : (r.outcome === 'running' ? ' ⏳' : (r.outcome ? ' ⚠' : ''))
|
|
1315
|
+
// 续跑标记(卡2 落的 runs[i].resume 字段;卡3 Step3 在此显式标注):同一条会话的第二次及以后
|
|
1316
|
+
// 派发是「冷复活续跑」而不是新开 Worker,不标出来会让人把「第 2/3 次」误读成又开了一个新会话。
|
|
1317
|
+
// 只读 r.resume,不猜(老留档没有该字段 → 零渲染,与改造前逐字一致)。
|
|
1318
|
+
var rsMark = r.resume === true ? ' ↻' : ''
|
|
1277
1319
|
return React.createElement('button', {
|
|
1278
1320
|
key: i, onClick: function () { if (uiWorkspaceSvc) uiWorkspaceSvc.openSession(r.id) },
|
|
1279
|
-
title: rm.tip + ' 第 ' + seq + ' 次' + (r.at ? ' · ' + ago(r.at) : '') + (r.model ? ' · ' + r.model : '') + (r.outcome ? ' · ' + r.outcome : '') + '(' + r.id + ')',
|
|
1321
|
+
title: rm.tip + ' 第 ' + seq + ' 次' + (r.resume === true ? '(↻ 冷复活续跑:沿用上一次的子会话,非新开)' : '') + (r.at ? ' · ' + ago(r.at) : '') + (r.model ? ' · ' + r.model : '') + (r.outcome ? ' · ' + r.outcome : '') + '(' + r.id + ')',
|
|
1280
1322
|
style: { fontSize: 10, padding: '2px 8px', border: '1px solid ' + (isCur ? rm.color : C.border), borderRadius: 3, background: isCur ? C.card : 'transparent', color: rm.color, cursor: 'pointer', fontWeight: isCur ? 600 : 400 }
|
|
1281
|
-
}, '→ ' + rm.tip + ' #' + seq + (r.at ? ' · ' + ago(r.at) : '') + okMark)
|
|
1323
|
+
}, '→ ' + rm.tip + ' #' + seq + (r.at ? ' · ' + ago(r.at) : '') + okMark + rsMark)
|
|
1282
1324
|
})))
|
|
1283
1325
|
})(),
|
|
1284
1326
|
canIntervene ? React.createElement('div', { style: { marginTop: 8, padding: '6px 8px', border: '1px dashed ' + C.warn, borderRadius: 6 } },
|
|
@@ -1421,7 +1463,14 @@ function apply(ctx) {
|
|
|
1421
1463
|
var _R = React; var useState = _R.useState
|
|
1422
1464
|
var _a = useState(state.rfOpen || false), open = _a[0], setOpen = _a[1]
|
|
1423
1465
|
var rg = activeRange()
|
|
1424
|
-
function setRange(from, to) {
|
|
1466
|
+
function setRange(from, to) {
|
|
1467
|
+
state.dateRange = { from: from, to: to }
|
|
1468
|
+
// 报告/总览是本地现算,notify 即可;Token 区的模型分布/Top8/累计要按范围重算,
|
|
1469
|
+
// 而过滤在 host(run 级数据只在 host)→ 必须重拉一次 get-tasks(fetchTasks 会带上新范围)。
|
|
1470
|
+
// fetchTasks 在 kernel 域定义,同处 apply 函数体 → 函数声明提升,此处可用。
|
|
1471
|
+
if (typeof fetchTasks === 'function') fetchTasks()
|
|
1472
|
+
notify()
|
|
1473
|
+
}
|
|
1425
1474
|
function preset(days) {
|
|
1426
1475
|
if (days === 0) { setRange('', ''); return }
|
|
1427
1476
|
var to = new Date(); var from = new Date(Date.now() - (days - 1) * 86400000)
|
|
@@ -1449,8 +1498,8 @@ function apply(ctx) {
|
|
|
1449
1498
|
React.createElement('input', { type: 'date', value: rg.from, onChange: function (e) { setRange(e.target.value, rg.to) }, style: dateInput })),
|
|
1450
1499
|
React.createElement('label', { style: { fontSize: 10, color: C.text2, display: 'inline-flex', alignItems: 'center', gap: 4 } }, '截至',
|
|
1451
1500
|
React.createElement('input', { type: 'date', value: rg.to, onChange: function (e) { setRange(rg.from, e.target.value) }, style: dateInput })),
|
|
1452
|
-
React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, '
|
|
1453
|
-
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 5 } }, '
|
|
1501
|
+
React.createElement('span', { style: { fontSize: 9, color: C.text2 } }, '作用于报告、全局总览与 Token 区(模型分布/Top8/累计)')),
|
|
1502
|
+
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 5 } }, '报告统计「时间范围内有活动」的任务;总览只显示范围内有活跃的会话;Token 区按 run 的本地日落点过滤(今日与近 7 天为固定口径,不受范围影响)。')) : null)
|
|
1454
1503
|
}
|
|
1455
1504
|
|
|
1456
1505
|
function GlobalBoards() {
|
|
@@ -1520,10 +1569,11 @@ function apply(ctx) {
|
|
|
1520
1569
|
// usageSummary 由 host 从各任务 t.usage 现算(t.usage 来自 Worker/Verifier 会话 v4 日志的
|
|
1521
1570
|
// assistant/message.usage 聚合)。这里只做展示、不做计费断言;日志读不到/还没有 run 结算时
|
|
1522
1571
|
// usageSummary.total 为 0,一律显示「暂无数据」。
|
|
1523
|
-
// 日账(usageSummary.
|
|
1572
|
+
// 日账(usageSummary.byDayFull:{'YYYY-MM-DD': {t,e}})用于「今日」大数字与「近 7 天」条形。
|
|
1524
1573
|
// 双指标口径:t = 总量(含缓存读)、e = 有效消耗(输入+输出+缓存写,不含缓存读)。
|
|
1525
1574
|
// e === null 表示该日只有老形态 number 日账 / 存量兜底(没有逐 run 拆分,有效值不可知)——
|
|
1526
|
-
//
|
|
1575
|
+
// 旧口径日不拿 t 冒充有效(task-muwsnyqv ③:本板 10-05 t=154M 含 97% 缓存读曾被画成巨柱):
|
|
1576
|
+
// 今日大数字显示「—」+ title 说明,近 7 天画矮灰柱 + tooltip 标「旧口径数据(仅总量)」。
|
|
1527
1577
|
// dayKey 口径与 host 记账完全一致——本地 getters 拼,绝不用 toISOString()(UTC 会让
|
|
1528
1578
|
// 晚上 8 点后的消耗落到次日,今日消耗直接错位)。
|
|
1529
1579
|
function localDayKey(d) {
|
|
@@ -1558,69 +1608,129 @@ function apply(ctx) {
|
|
|
1558
1608
|
function TokenUsage(props) {
|
|
1559
1609
|
var u = props.usage
|
|
1560
1610
|
var box = { padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6, marginBottom: 12 }
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
var
|
|
1564
|
-
var
|
|
1565
|
-
var
|
|
1566
|
-
|
|
1567
|
-
//
|
|
1611
|
+
// 范围激活标记:Token 区的模型分布/Top8/累计已按「统计范围」过滤(host 侧重算),
|
|
1612
|
+
// 不加标记的话用户没法判断看到的数字是全量还是范围内——标记与 RangeFilter 同源(activeRange)。
|
|
1613
|
+
var tg = activeRange()
|
|
1614
|
+
var tgOn = !!(tg.from || tg.to)
|
|
1615
|
+
var head = React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 4 } }, ic('bar-chart-3', 11), 'Token 消耗',
|
|
1616
|
+
tgOn ? React.createElement('span', { style: { fontSize: 9, fontWeight: 400, color: C.brand, border: '1px solid ' + C.brand, borderRadius: 8, padding: '0 6px' }, title: '模型分布 / 任务消耗 Top 8 / 累计已按统计范围过滤:' + rangeLabel() }, '范围内: ' + rangeLabel()) : null)
|
|
1617
|
+
// ===== 主窗口(本会话对话)消耗(task-muwsol23):host 增量尾读主会话 v4 日志聚合 =====
|
|
1618
|
+
// 隔离口径:只在 Token 区单列一行 + 模型分布尾部追加一条;不进累计 / Top 8 / 模型归属摊派。
|
|
1619
|
+
// 缺字段(老 host)/ 读不到日志 / 无 usage → mw=null → 两处都不渲染(静默降级,不炸轮询)。
|
|
1620
|
+
var mw = (u && u.mainWindow && typeof u.mainWindow === 'object' && typeof u.mainWindow.effective === 'number') ? u.mainWindow : null
|
|
1621
|
+
var todayKey0 = localDayKey()
|
|
1622
|
+
var mwToday = 0
|
|
1623
|
+
if (mw && mw.byDay && typeof mw.byDay === 'object') {
|
|
1624
|
+
var mwc0 = mw.byDay[todayKey0]
|
|
1625
|
+
mwToday = (mwc0 && typeof mwc0.e === 'number') ? mwc0.e : 0
|
|
1626
|
+
}
|
|
1627
|
+
// 主窗口单列行(累计行下方):「主窗口(本会话):今日有效 X · 累计 Y(缓存读 Z)」
|
|
1628
|
+
function mwLine() {
|
|
1629
|
+
if (!mw) return null
|
|
1630
|
+
return React.createElement('div', { style: { fontSize: 10, color: C.text2, marginBottom: 8 }, title: '主窗口(本会话对话)消耗:host 增量尾读本会话 v4 日志聚合,有效口径 = 输入+输出+缓存写(不含缓存读)' + (tgOn ? ';随统计范围裁剪(按助手消息的本地日落点)' : '') + '。与看板派发口径并列展示——不进累计 / Top 8 / 模型归属摊派' },
|
|
1631
|
+
'主窗口(本会话):今日有效 ',
|
|
1632
|
+
React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(mwToday)),
|
|
1633
|
+
' · 累计 ',
|
|
1634
|
+
React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(mw.effective)),
|
|
1635
|
+
'(缓存读 ',
|
|
1636
|
+
React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(mw.cacheRead)),
|
|
1637
|
+
')')
|
|
1638
|
+
}
|
|
1639
|
+
if (!u || !u.total) return React.createElement('div', { style: box }, head, React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '暂无数据(Worker/Verifier 会话日志里还没有 usage 记录)'), mwLine())
|
|
1640
|
+
// ===== 主数字口径统一(task-muwq9u04):模型分布行与 Top8 行的主数字一律显示**有效消耗**(e),
|
|
1641
|
+
// 含缓存读的合计(t)退到 title 悬浮;否则同一块里「今日有效 3.7M」与「单任务 21M」并排自相矛盾
|
|
1642
|
+
// (用户就是这么判成 bug 的)。数据源:host 的 byModelEff(模型有效分摊)与 topTasks[].effective;
|
|
1643
|
+
// 老 host 缺 byModelEff / 老卡无五分量留账 → 退化用合计值并在 title 标 ~(口径不伪造)。
|
|
1644
|
+
// 排序按**显示口径**(有效)降序:条形长度与行序一致,否则首行不是最长的条。
|
|
1645
|
+
var models = Object.keys(u.byModel || {}).map(function (m) {
|
|
1646
|
+
var tt = u.byModel[m] || 0
|
|
1647
|
+
var ee = (u.byModelEff && typeof u.byModelEff[m] === 'number') ? u.byModelEff[m] : null
|
|
1648
|
+
return { model: m, total: tt, eff: (ee === null ? tt : ee), approx: ee === null }
|
|
1649
|
+
}).sort(function (a, b) { return b.eff - a.eff })
|
|
1650
|
+
var maxM = models.length ? (models[0].eff || 1) : 1
|
|
1651
|
+
// 主窗口行与 run 模型同一把尺(参与归一,条形长度才可比),但固定尾部追加、不参与排序(见渲染处)
|
|
1652
|
+
if (mw && mw.effective > maxM) maxM = mw.effective
|
|
1653
|
+
var top = (u.topTasks || []).map(function (x) {
|
|
1654
|
+
var ee = (typeof x.effective === 'number') ? x.effective : null
|
|
1655
|
+
return { id: x.id, title: x.title, total: x.total, cacheRead: x.cacheRead, runs: x.runs, eff: (ee === null ? x.total : ee), approx: ee === null }
|
|
1656
|
+
}).sort(function (a, b) { return b.eff - a.eff })
|
|
1657
|
+
var maxT = top.length ? (top[0].eff || 1) : 1
|
|
1658
|
+
// 日账:今日数字取本地日 key,没有日账(缺字段/老 host)时退化为 0,不炸也不误报。
|
|
1568
1659
|
// 读侧兼容两种单元形态:老 number(只有总量,有效值不可知)→ { t: n, e: null }。
|
|
1569
|
-
|
|
1660
|
+
// 固定口径数据源(task-muwsnyqv ②):「今日」「近 7 天」读**未过滤**的 byDayFull——caption
|
|
1661
|
+
// 承诺这两处不随统计范围变,读范围内 byDay 会让「今天被范围裁掉」时大数字凭空归零(实证)。
|
|
1662
|
+
// 老 host 缺 byDayFull → 退化读 byDay(范围内口径,数值可能偏小但形态一致,不炸)。
|
|
1663
|
+
var byDay = (u.byDayFull && typeof u.byDayFull === 'object') ? u.byDayFull : ((u.byDay && typeof u.byDay === 'object') ? u.byDay : {})
|
|
1570
1664
|
function dayOf(v) {
|
|
1571
1665
|
if (v && typeof v === 'object') return { t: Number(v.t) || 0, e: (v.e === 0 || v.e) ? Number(v.e) || 0 : null }
|
|
1572
1666
|
return { t: Number(v) || 0, e: null }
|
|
1573
1667
|
}
|
|
1574
1668
|
var todayKey = localDayKey()
|
|
1575
1669
|
var todayCell = dayOf(byDay[todayKey])
|
|
1576
|
-
//
|
|
1577
|
-
|
|
1578
|
-
var
|
|
1670
|
+
// 旧口径日(e===null 且有总量:老 number 日账/存量兜底,只有含缓存读的总量)不拿 t 冒充有效
|
|
1671
|
+
// (task-muwsnyqv ③)——大数字显示「—」+ title 说明;t=0 且无日账 = 今日尚无结算,照常显示 0。
|
|
1672
|
+
var todayLegacy = todayCell.e === null && todayCell.t > 0
|
|
1673
|
+
var todayEff = todayCell.e === null ? 0 : todayCell.e
|
|
1579
1674
|
var days = lastNDays(7)
|
|
1580
1675
|
var maxDay = 1
|
|
1581
1676
|
var hasDayData = false
|
|
1582
|
-
|
|
1677
|
+
var hasLegacy = false
|
|
1678
|
+
// maxDay 只按有效消耗归一:旧口径日(e===null)的 t 含缓存读(本板实测占 97%),参与归一会
|
|
1679
|
+
// 把其余日子的有效柱压成平地(task-muwsnyqv ③:10-05 t=154M 巨柱就是这么来的)。
|
|
1680
|
+
for (var di = 0; di < days.length; di++) { var dc = dayOf(byDay[days[di]]); if (dc.e !== null && dc.e > maxDay) maxDay = dc.e; if (dc.e === null && dc.t > 0) hasLegacy = true; if (dc.t > 0 || dc.e > 0) hasDayData = true }
|
|
1583
1681
|
function mini(label, value) { return React.createElement('span', { style: { fontSize: 10, color: C.text2 } }, label + ' ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(value))) }
|
|
1584
1682
|
// 累计三分量:有效(真实成本)+ 缓存读(占了 total 的大头,必须单列才看得出虚高来源)+ 合计
|
|
1585
1683
|
var effTotal = (typeof u.effective === 'number') ? u.effective : ((u.input || 0) + (u.output || 0) + (u.cacheWrite || 0))
|
|
1586
1684
|
return React.createElement('div', { style: box },
|
|
1587
1685
|
head,
|
|
1588
1686
|
React.createElement('div', { style: { display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap', marginBottom: 8 } },
|
|
1589
|
-
React.createElement('span', { style: { fontSize: 20, fontWeight: 700, color: C.brand }, title: '
|
|
1590
|
-
React.createElement('span', { style: { fontSize: 10, color: C.text2 } }, 'tokens(今日有效' + (
|
|
1591
|
-
todayCell.t > todayEff ? React.createElement('span', { style: { fontSize: 10, color: C.text2 }, title: '今日总量(含缓存读)——与有效消耗的差额就是缓存读' }, '含缓存读共 ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(todayCell.t))) : null,
|
|
1687
|
+
React.createElement('span', { style: { fontSize: 20, fontWeight: 700, color: todayLegacy ? C.text2 : C.brand }, title: todayLegacy ? ('今日有效消耗不可知:该日账为旧口径数据(仅总量 ' + fmtTokens(todayCell.t) + ' tok,含缓存读),总量不冒充有效值') : ('今日有效消耗(本地日 ' + todayKey + ')= 输入+输出+缓存写,不含缓存读;一次 run 的消耗整笔记在结算日') }, todayLegacy ? '—' : fmtTokens(todayEff)),
|
|
1688
|
+
React.createElement('span', { style: { fontSize: 10, color: C.text2 } }, 'tokens(今日有效' + (todayLegacy ? ' · 旧口径不可知' : '') + ')'),
|
|
1689
|
+
todayCell.t > todayEff ? React.createElement('span', { style: { fontSize: 10, color: C.text2 }, title: '今日总量(含缓存读)——与有效消耗的差额就是缓存读' + (todayLegacy ? ';该日为旧口径数据,有效消耗不可知' : '') }, '含缓存读共 ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(todayCell.t))) : null,
|
|
1592
1690
|
React.createElement('span', { style: { fontSize: 10, color: C.text2 } }, '累计(本看板) 有效 ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(effTotal)), ' · 缓存读 ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(u.cacheRead)), ' · 合计 ', React.createElement('span', { style: { color: C.text, fontWeight: 600 } }, fmtTokens(u.total))),
|
|
1593
1691
|
mini('输入', u.input), mini('输出', u.output), u.cacheWrite ? mini('缓存写', u.cacheWrite) : null),
|
|
1692
|
+
// 主窗口(本会话对话)单列行:累计行下方,与看板派发口径并列不混入(mw=null 静默不渲染)
|
|
1693
|
+
mwLine(),
|
|
1594
1694
|
// 近 7 天迷你条形:高按区间 max 归一(今天高亮 brand,其余浅底 + 边框),
|
|
1595
|
-
// 柱高一律取**有效消耗**;e
|
|
1596
|
-
// 7 天全为 0
|
|
1695
|
+
// 柱高一律取**有效消耗**;e 不可知的旧口径日不拿总量冒充——画固定矮灰柱(tooltip 标明
|
|
1696
|
+
// 「旧口径数据(仅总量,含缓存读)」),有效柱高不被缓存读撑歪。7 天全为 0 时整块不渲染。
|
|
1597
1697
|
hasDayData ? React.createElement('div', { style: { marginBottom: 8 } },
|
|
1598
|
-
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '近 7 天(有效消耗)'),
|
|
1698
|
+
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '近 7 天(有效消耗)', hasLegacy ? React.createElement('span', { style: { fontSize: 9, fontWeight: 400, marginLeft: 6 } }, '灰柱 = 旧口径数据(仅总量)') : null),
|
|
1599
1699
|
React.createElement('div', { style: { display: 'flex', alignItems: 'flex-end', gap: 4 } },
|
|
1600
1700
|
days.map(function (k) {
|
|
1601
1701
|
var c = dayOf(byDay[k])
|
|
1602
|
-
|
|
1603
|
-
|
|
1702
|
+
// 旧口径日(e===null 且有总量):不拿 t 冒充有效画柱(task-muwsnyqv ③)——
|
|
1703
|
+
// 画固定矮灰柱 + tooltip 标明;有效日柱高照常按有效消耗归一。
|
|
1704
|
+
var legacy = c.e === null && c.t > 0
|
|
1705
|
+
var v = legacy ? 0 : (c.e || 0)
|
|
1604
1706
|
var isToday = k === todayKey
|
|
1605
|
-
var h = v > 0 ? Math.max(3, Math.round(v / maxDay * 32)) : 3
|
|
1606
|
-
return React.createElement('div', { key: k, title: k.slice(5) + '
|
|
1607
|
-
React.createElement('div', { style: { width: '100%', height: h, background: isToday ? C.brand : C.nested, border: '1px solid ' + (isToday ? C.brand : C.border), borderRadius: 2 } }),
|
|
1707
|
+
var h = legacy ? 6 : (v > 0 ? Math.max(3, Math.round(v / maxDay * 32)) : 3)
|
|
1708
|
+
return React.createElement('div', { key: k, title: legacy ? (k.slice(5) + ':旧口径数据(仅总量 ' + String(c.t) + ' tok,含缓存读;有效消耗不可知,不拿总量画柱)') : (k.slice(5) + ':有效 ' + String(v) + ' tok' + (c.t > v ? ' / 含缓存读共 ' + String(c.t) + ' tok' : '')), style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 } },
|
|
1709
|
+
React.createElement('div', { style: { width: '100%', height: h, background: legacy ? C.border : (isToday ? C.brand : C.nested), border: '1px solid ' + (legacy ? C.border2 : (isToday ? C.brand : C.border)), borderRadius: 2 } }),
|
|
1608
1710
|
React.createElement('span', { style: { fontSize: 8, color: isToday ? C.brand : C.text2, whiteSpace: 'nowrap' } }, k.slice(5)))
|
|
1609
1711
|
}))) : null,
|
|
1610
1712
|
React.createElement('div', { style: { display: 'flex', gap: 12, flexWrap: 'wrap' } },
|
|
1611
1713
|
React.createElement('div', { style: { flex: '1 1 240px', minWidth: 200 } },
|
|
1612
|
-
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '
|
|
1714
|
+
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '按模型分布(有效消耗)'),
|
|
1613
1715
|
models.length === 0 ? React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '暂无数据') : models.map(function (m) {
|
|
1614
|
-
return React.createElement(UsageRow, { key: m.model, label: m.model, value: m.
|
|
1615
|
-
})
|
|
1716
|
+
return React.createElement(UsageRow, { key: m.model, label: m.model, value: m.eff, max: maxM, color: C.brand, title: m.model + ':有效 ' + (m.approx ? '~' : '') + String(m.eff) + ' tokens(不含缓存读)' + (m.approx ? '——本模型无有效分量留账,以合计近似' : '') + ' · 含缓存读合计 ' + String(m.total) + ' tokens' })
|
|
1717
|
+
}),
|
|
1718
|
+
// 模型分布尾部追加「主窗口(对话)」一条(task-muwsol23):有效为 0 不渲染;
|
|
1719
|
+
// 琥珀色与 run 模型(brand)区分,固定尾部不参与排序,与派发口径并列不混入
|
|
1720
|
+
(mw && mw.effective > 0) ? React.createElement(UsageRow, { key: 'main-window', label: '主窗口(对话)', value: mw.effective, max: maxM, color: C.warn, title: '主窗口(对话):有效 ' + String(mw.effective) + ' tokens(不含缓存读)· 缓存读 ' + String(mw.cacheRead) + ' tokens——本会话对话消耗,与看板派发口径并列不混入(不进累计 / Top 8)' }) : null),
|
|
1616
1721
|
React.createElement('div', { style: { flex: '1 1 240px', minWidth: 200 } },
|
|
1617
|
-
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '任务消耗 Top 8'),
|
|
1722
|
+
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '任务消耗 Top 8(有效消耗)'),
|
|
1618
1723
|
top.length === 0 ? React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '暂无数据') : top.map(function (x) {
|
|
1619
1724
|
var t = getTask(x.id)
|
|
1620
|
-
return React.createElement(UsageRow, { key: x.id, label: x.title || x.id, value: x.
|
|
1725
|
+
return React.createElement(UsageRow, { key: x.id, label: x.title || x.id, value: x.eff, max: maxT, color: C.ok, labelColor: t ? C.brand : C.text2, title: x.title + '(有效 ' + (x.approx ? '~' : '') + String(x.eff) + ' tokens(不含缓存读) · 含缓存读合计 ' + String(x.total) + ' · 其中缓存读 ' + String(x.cacheRead || 0) + ' · ' + (x.runs || 0) + ' 次 run)' + (x.approx ? '(老卡无五分量留账,有效值以合计近似)' : '') + (t ? '——点击查看详情' : ''), onClick: t ? function () { state.detailId = x.id; notify() } : undefined })
|
|
1621
1726
|
}))),
|
|
1622
|
-
//
|
|
1623
|
-
|
|
1727
|
+
// 口径边界:累计 / Top 8 只统计看板派发的 Worker/Verifier run;主窗口对话消耗自 task-muwsol23 起
|
|
1728
|
+
// 单列一行 + 模型分布尾部一条并列展示(不越界混入累计/Top8/归属摊派),caption 必须讲清并列关系。
|
|
1729
|
+
// 范围说明(task-muwc7hjd):用户常把 RangeFilter 当成「整页过滤」,但今日大数字与近 7 天柱子
|
|
1730
|
+
// 是自身固定口径(今日=本地今天、近 7 天=最近 7 个本地日)——不随范围变,必须在文案里讲清,
|
|
1731
|
+
// 否则「选了范围数字没变」看起来像 bug。模型分布/Top8/累计才是范围生效的三处。
|
|
1732
|
+
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:累计与 Top 8 仅看板派发的 Worker/Verifier run 消耗;主窗口行=本会话对话消耗,与看板派发口径并列不混入(单列一行 + 模型分布尾部一条,不进累计 / Top 8);主数字(模型分布 / Top 8)与「今日」「近 7 天」均为有效消耗口径(输入+输出+缓存写,不含缓存读),含缓存读的合计在悬浮 title 里单列对照'),
|
|
1733
|
+
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 2, lineHeight: 1.5 } }, '统计范围作用于按模型分布 / 任务消耗 Top 8 / 累计三分量(按 run 的本地日落点过滤);今日与「近 7 天」为固定口径,不随范围变化。'))
|
|
1624
1734
|
}
|
|
1625
1735
|
|
|
1626
1736
|
// ===== 架构健康提示区(架构自省 L1 · 数据源:state.healthHints)=====
|
|
@@ -1756,6 +1866,19 @@ function apply(ctx) {
|
|
|
1756
1866
|
var _a = useState(false), open = _a[0], setOpen = _a[1]
|
|
1757
1867
|
var _p = useState(null), pos = _p[0], setPos = _p[1]
|
|
1758
1868
|
var ref = useRef(null); var btnRef = useRef(null)
|
|
1869
|
+
// 开关点击即时反馈(反馈:勾选几秒才同步,task-muw5uudk):
|
|
1870
|
+
// checked 是受控值(读 Props→state,由 3s 轮询通知才更新),此前 onChange 只发 rpc →
|
|
1871
|
+
// 点下去要等 set-board-config + fetchTasks 双往返(安静板卡上 tasksHash 不变还会被渲染节约吃掉)才翻面。
|
|
1872
|
+
// 口径:**先写 state + notify(点击瞬时翻面)→ 再 rpc('set-board-config') → 失败回滚 state + notify
|
|
1873
|
+
// 并复用错误条 reportReadErr(沿用「读路径错误防线」:绝不让开关停在未落盘的值上)**。
|
|
1874
|
+
// 成功路径的权威纠偏在 kernel.fetchTasks(配置变化检测),此处不重复 fetchTasks:3s 轮询自会带回真值。
|
|
1875
|
+
function cfgRollback(key, prev) { state[key] = prev; notify() }
|
|
1876
|
+
function setCfg(key, next) {
|
|
1877
|
+
var prev = !!state[key] // 乐观更新前的值,失败回滚用
|
|
1878
|
+
state[key] = next === true // 归一成布尔:默认开口径由服务端权威值(fetchTasks)纠偏
|
|
1879
|
+
notify() // 立即翻面,不等 rpc
|
|
1880
|
+
rpc('set-board-config', { key: key, value: next === true }).catch(function (e) { cfgRollback(key, prev); reportReadErr('设置保存失败:' + readErrText(e)) })
|
|
1881
|
+
}
|
|
1759
1882
|
useEffect(function () { if (!open) return; function onDown(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }; document.addEventListener('mousedown', onDown); return function () { document.removeEventListener('mousedown', onDown) } }, [open])
|
|
1760
1883
|
function toggle() {
|
|
1761
1884
|
// position:fixed + 视口坐标:面板容器是 overflow:hidden + maxHeight:60vh,
|
|
@@ -1784,16 +1907,16 @@ function apply(ctx) {
|
|
|
1784
1907
|
// 学习飞轮 v1 总开关:关掉后不生成候选教训、prompt 不提软召回、详情页不渲染「沉淀」按钮
|
|
1785
1908
|
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '学习反馈'),
|
|
1786
1909
|
React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: C.text, cursor: 'pointer', whiteSpace: 'normal', maxWidth: 260 } },
|
|
1787
|
-
React.createElement('input', { type: 'checkbox', checked: !!props.feedbackEnabled, onChange: function (e) {
|
|
1910
|
+
React.createElement('input', { type: 'checkbox', checked: !!props.feedbackEnabled, onChange: function (e) { setCfg('feedbackEnabled', e.target.checked) } }),
|
|
1788
1911
|
React.createElement('span', null, '候选教训(Verifier 驳回/仲裁结论自动生成候选,Worker prompt 提示先检索历史教训)')),
|
|
1789
1912
|
// 回执开关(用户要求「回执可以做一个开关,放到设置里」):派发/完成两类回执各自可关,缺省都开。
|
|
1790
1913
|
// 只影响回执播报,不影响派发与状态机;歧义裁决通知不在此闸门内(见下行说明文案)。
|
|
1791
1914
|
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '通知'),
|
|
1792
1915
|
React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: C.text, cursor: 'pointer', whiteSpace: 'normal', maxWidth: 260 } },
|
|
1793
|
-
React.createElement('input', { type: 'checkbox', checked: props.notifyDispatch !== false, onChange: function (e) {
|
|
1916
|
+
React.createElement('input', { type: 'checkbox', checked: props.notifyDispatch !== false, onChange: function (e) { setCfg('notifyDispatch', e.target.checked) } }),
|
|
1794
1917
|
React.createElement('span', null, '⚡ 派发回执(任务被 Worker/Verifier 领走时播报)')),
|
|
1795
1918
|
React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: C.text, cursor: 'pointer', whiteSpace: 'normal', maxWidth: 260, marginTop: 3 } },
|
|
1796
|
-
React.createElement('input', { type: 'checkbox', checked: props.notifyDone !== false, onChange: function (e) {
|
|
1919
|
+
React.createElement('input', { type: 'checkbox', checked: props.notifyDone !== false, onChange: function (e) { setCfg('notifyDone', e.target.checked) } }),
|
|
1797
1920
|
React.createElement('span', null, '✅ 完成回执(任务完成或阻塞时聚合播报)')),
|
|
1798
1921
|
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 3, whiteSpace: 'normal', maxWidth: 260 } }, '歧义裁决通知不受这两个开关影响(任务等人裁决必须提醒)'),
|
|
1799
1922
|
// 史诗拆分总开关(板级 epicSplit,缺省 true):**只关引导,不禁机制**——关掉后 Team 提示词不再
|
|
@@ -1801,7 +1924,7 @@ function apply(ctx) {
|
|
|
1801
1924
|
// 子卡、史诗自动收口/hooks 状态机照常(用户/主窗口明确要拆时不受阻)。勾选态缺字段=开,与 host 同口径。
|
|
1802
1925
|
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '功能'),
|
|
1803
1926
|
React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 10, color: C.text, cursor: 'pointer', whiteSpace: 'normal', maxWidth: 260 } },
|
|
1804
|
-
React.createElement('input', { type: 'checkbox', checked: props.epicSplit !== false, onChange: function (e) {
|
|
1927
|
+
React.createElement('input', { type: 'checkbox', checked: props.epicSplit !== false, onChange: function (e) { setCfg('epicSplit', e.target.checked) } }),
|
|
1805
1928
|
React.createElement('span', null, '🧩 史诗拆分:大任务引导拆为 epic + 子任务')),
|
|
1806
1929
|
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 3, whiteSpace: 'normal', maxWidth: 260 } }, '关掉只停引导:显式 parentId 建子卡与史诗自动收口照常工作')) : null)
|
|
1807
1930
|
}
|
package/lib/core.mjs
CHANGED
|
@@ -22,7 +22,7 @@ export function vt(d) { return d && typeof d === 'object' && Array.isArray(d.tas
|
|
|
22
22
|
// 背景:并行 Worker 改同一批文件会互踩(diff 冲突/一方覆盖另一方)。任务可声明
|
|
23
23
|
// touches: string[](glob),派发器发现与「持锁任务的 touches」冲突
|
|
24
24
|
// 则本轮跳过该候选(记入 pickDispatch 返回的 blockedTouches),等锁释放再派。
|
|
25
|
-
// 持锁口径见 holdsFiles(in-progress/verifying/
|
|
25
|
+
// 持锁口径见 holdsFiles(in-progress + claimedBy / verifying 持锁;resolved/cancelled/archived 即放)。
|
|
26
26
|
// 语义(宁可偏严不可漏拦):
|
|
27
27
|
// 1. 先归一化:\ → /、去 './' 前缀、去尾部 '/';空串或非字符串忽略。
|
|
28
28
|
// 2. a === b 视为冲突。
|
|
@@ -126,10 +126,15 @@ export function classifyPipeline(t) {
|
|
|
126
126
|
// 边界:task_create 工具描述里的 TASK_SIZE_CONTRACT 是**静态工具契约**(工具定义会快照进 request header,
|
|
127
127
|
// dsh-session 校验 description 必须是 string),没有按板动态能力,因此不随本开关走——这也是「关引导」
|
|
128
128
|
// 只覆盖两处**动态引导**(Team 提示词条款 + suggestSplit 软提示)的原因。
|
|
129
|
+
// Worker 可续跑开关 workerContinuable(task-muw5gnhv,缺省 true = 新行为即默认):门禁**只在
|
|
130
|
+
// dispatch.spawnOneShot 的 Worker 分支**生效——true 走 subagents.startContinuable(rec 持 childId,
|
|
131
|
+
// turn 结算靠 host 事件 agent/status running→idle),false 逐字回退旧的 subagents.start() 一次性路径
|
|
132
|
+
// (run.result 结算)。Verifier/hook run 一律照旧 one-shot,不看这个键(它们无续跑语义)。
|
|
133
|
+
// 消费点只有派发引擎(spawn 时读一次 cfg 快照),所以不需要 feedbackEnabled/epicSplit 那样的热路径缓存。
|
|
129
134
|
export function cfg(d) {
|
|
130
135
|
var soft = Math.max(1, Math.min(480, d.softTimeoutMin || 30))
|
|
131
136
|
var hard = Math.max(soft, Math.min(1440, d.hardTimeoutMin || 120))
|
|
132
|
-
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, notifyDispatch: d.notifyDispatch !== false, notifyDone: d.notifyDone !== false, epicSplit: d.epicSplit !== false }
|
|
137
|
+
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, notifyDispatch: d.notifyDispatch !== false, notifyDone: d.notifyDone !== false, epicSplit: d.epicSplit !== false, workerContinuable: d.workerContinuable !== false }
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
// ===== 看板数据目录(跨重启继承用)=====
|
|
@@ -146,7 +151,7 @@ export function boardHome() { return path.join(boardDirName(), '.dsh') }
|
|
|
146
151
|
// ownerCwd(跨重启继承):创建该看板的会话工作区路径,继承判定全靠它——取不到就省略字段
|
|
147
152
|
// (绝不落空串,否则「路径读不到的多个会话」会被误判成同一工作区)。
|
|
148
153
|
export function seed(sid, ownerCwd) {
|
|
149
|
-
var d = { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, poolStatus: { workers: [], verifiers: [] }, tasks: [] }
|
|
154
|
+
var d = { version: 12, ownerSession: sid, boardMode: 'auto', teamMode: false, feedbackEnabled: true, notifyDispatch: true, notifyDone: true, epicSplit: true, workerContinuable: true, minWorkers: 1, maxWorkers: 3, minVerifiers: 0, maxVerifiers: 2, workerModel: '', verifierModel: '', softTimeoutMin: 30, hardTimeoutMin: 120, poolStatus: { workers: [], verifiers: [] }, tasks: [] }
|
|
150
155
|
if (typeof ownerCwd === 'string' && ownerCwd) d.ownerCwd = ownerCwd
|
|
151
156
|
return d
|
|
152
157
|
}
|
|
@@ -156,6 +161,8 @@ export function seed(sid, ownerCwd) {
|
|
|
156
161
|
// feedbackEnabled 兼容:老看板没有该字段(或落了脏值)一律补 true——默认开,行为与 v1 之前一致。
|
|
157
162
|
// notifyDispatch/notifyDone(回执开关)同法:老看板文件没有该字段 → 补 true,缺省开 = 现状不变。
|
|
158
163
|
// epicSplit(史诗拆分总开关)同法:老看板没有该字段(或脏值)→ 补 true,缺省开 = 引导照旧。
|
|
164
|
+
// workerContinuable(Worker 可续跑开关)同法:老看板没有该字段(或脏值)→ 补 true,
|
|
165
|
+
// 即老看板读进来就按新行为(可续跑 Worker)派发;显式落 false 才是逐字回退旧一次性路径。
|
|
159
166
|
export function normalizeBoard(d) {
|
|
160
167
|
if (d && typeof d === 'object') {
|
|
161
168
|
if (!d.poolStatus || typeof d.poolStatus !== 'object' || !Array.isArray(d.poolStatus.workers) || !Array.isArray(d.poolStatus.verifiers)) d.poolStatus = { workers: [], verifiers: [] }
|
|
@@ -163,6 +170,7 @@ export function normalizeBoard(d) {
|
|
|
163
170
|
if (typeof d.notifyDispatch !== 'boolean') d.notifyDispatch = true
|
|
164
171
|
if (typeof d.notifyDone !== 'boolean') d.notifyDone = true
|
|
165
172
|
if (typeof d.epicSplit !== 'boolean') d.epicSplit = true
|
|
173
|
+
if (typeof d.workerContinuable !== 'boolean') d.workerContinuable = true
|
|
166
174
|
if (Array.isArray(d.tasks)) {
|
|
167
175
|
for (var i = 0; i < d.tasks.length; i++) {
|
|
168
176
|
var t = d.tasks[i]
|
|
@@ -492,28 +500,45 @@ export function buildMessages(t) {
|
|
|
492
500
|
if (!Array.isArray(t.messages) || t.messages.length === 0) return ''
|
|
493
501
|
return t.messages.map(function (m) { return '### [' + (m.kind || 'note') + '] (' + (m.at || '') + ' by ' + (m.by || '') + ')\n' + String(m.text || '').slice(0, 2000) }).join('\n\n')
|
|
494
502
|
}
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
503
|
+
// 主窗口预研上下文段(瘦身分离形态,task-muvjs392):**笔记全文 + 文件清单**,
|
|
504
|
+
// 由 host 组装后传入(core 保持纯函数不碰 IO)。文件内容本体不进 prompt——由 Worker 用 read 工具
|
|
505
|
+
// 按行号范围自取(执行时盘面,更新鲜),既免了大文件正文撑爆 prompt,也免了随 runtime 快照每轮重发
|
|
506
|
+
//(旧形态走 systemPrompt.context,自治 run 中实测 6×48.8K 字符≈白烧 75–100K token)。
|
|
507
|
+
// 注意:{{ }} 净化沿用「systemPrompt context 严格插值」时代的封闭变换(内容里 {{...}} 会直接抛异常
|
|
508
|
+
// 毁掉子代理的整个 prompt 组装)。通道虽已退役,净化是幂等纯变换且单测锁定口径,保留以防历史笔记复发。
|
|
498
509
|
// 教训:replace(/\{\{/g,'{ {') 是开变换——三连括号 {{{lo}}}(Python f-string)会得到
|
|
499
510
|
// "{ {{lo}}}",替换结果自己又造出 {{。按"连 { 整 run 拆开"才是封闭变换。
|
|
500
511
|
function sanitizeCtx(s) { return String(s).replace(/\{+/g, function (m) { return m.length === 1 ? m : m.split('').join(' ') }) }
|
|
512
|
+
// contextFiles 条目写法:「路径[:L起[-L止]][ — 一句用途]」。
|
|
513
|
+
// 用途可选(缺省只给路径行号,不造新字段):分隔符用「 — 」(em dash 带两侧空格,路径/锚点里不会出现)。
|
|
514
|
+
// 锚点按 parseAnchorPath 口径归一(a.js:L10-20 → a.js:L10-L20;非法锚点剥掉,不误导 Worker 去读空段)。
|
|
515
|
+
// 返回 { path: 清单展示串(含归一锚点), file: 纯路径(存在性预检用,剥掉锚点与用途), usage }。
|
|
516
|
+
export function parseContextFileEntry(entry) {
|
|
517
|
+
var s = String(entry == null ? '' : entry).trim()
|
|
518
|
+
var usage = ''
|
|
519
|
+
var sep = s.indexOf(' — ')
|
|
520
|
+
if (sep > 0) { usage = s.slice(sep + 3).trim(); s = s.slice(0, sep).trim() }
|
|
521
|
+
var a = parseAnchorPath(s)
|
|
522
|
+
var p = a.from != null ? (a.file + ':L' + a.from + (a.to != null ? '-L' + a.to : '')) : a.file
|
|
523
|
+
return { path: p, file: a.file, usage: usage }
|
|
524
|
+
}
|
|
525
|
+
// 组装瘦身清单:笔记段(全文,调用方已按 8000 字符截断)+ 文件清单段(每行「路径:L行号 — 一句用途」)。
|
|
526
|
+
// files 收 { path, usage }(兼容裸字符串);两者皆空 → 空串(调用方据此跳过注入,保持无清单 parity)。
|
|
501
527
|
export function buildContextPackSection(files, notes) {
|
|
502
528
|
var parts = []
|
|
503
529
|
if (notes && String(notes).trim()) parts.push('### 主窗口调研笔记(结论/思路/原始需求,直接采信)\n' + sanitizeCtx(String(notes)))
|
|
504
|
-
|
|
505
|
-
|
|
530
|
+
var list = []
|
|
531
|
+
if (Array.isArray(files)) {
|
|
506
532
|
for (var i = 0; i < files.length; i++) {
|
|
507
533
|
var f = files[i]
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
parts.push('结构索引(' + sanitizeCtx(f.path) + ' 的函数/标题及行号,可用 路径:L起-L止 锚点补读):\n' + f.outline.map(function (x) { return sanitizeCtx(x) }).join('\n'))
|
|
514
|
-
}
|
|
534
|
+
var path = (typeof f === 'string') ? f : (f && f.path)
|
|
535
|
+
if (!path) continue
|
|
536
|
+
var line = '- ' + sanitizeCtx(path)
|
|
537
|
+
if (f && typeof f === 'object' && f.usage) line += ' — ' + sanitizeCtx(f.usage)
|
|
538
|
+
list.push(line)
|
|
515
539
|
}
|
|
516
540
|
}
|
|
541
|
+
if (list.length) parts.push('### 调研文件清单(附带的调研文件清单请按需用 read 工具自行读取,不要全量盲读;给出行号的按行号范围读)\n' + list.join('\n'))
|
|
517
542
|
return parts.length ? parts.join('\n\n') : ''
|
|
518
543
|
}
|
|
519
544
|
|
|
@@ -521,6 +546,9 @@ export function buildContextPackSection(files, notes) {
|
|
|
521
546
|
// 背景:contextFiles 注入大文件被 8KB 头部截断,目标代码段在中部/尾部,Worker 仍需全文盘点;
|
|
522
547
|
// 且只标"截断"二字,不知道截掉了什么、该补读哪里。
|
|
523
548
|
// 三个纯函数落在这里可单测;零依赖纯行正则,宁可漏检不可误切(不引 parser)。
|
|
549
|
+
// 【task-muvjs392 后现状】派发侧已改为「清单 + Worker 按需 read 自取」,host 不再读盘、不再切段/附索引:
|
|
550
|
+
// parseAnchorPath 仍在使用(清单条目归一,见 parseContextFileEntry);sliceLines / buildFileOutline
|
|
551
|
+
// 暂留为纯工具(口径由单测锁定),不删以免丢历史行为证据、也备将来主窗口要行段预切时复用——宿主路径上已无调用点。
|
|
524
552
|
// 锚点行段长度上限(行):超出截断到该上限并标记 capped
|
|
525
553
|
export var CONTEXT_SLICE_MAX_LINES = 400
|
|
526
554
|
// 结构索引条数上限:超出附一条省略标注
|
|
@@ -667,18 +695,21 @@ export function buildHookPrompt(epic, phase, childTasks) {
|
|
|
667
695
|
}
|
|
668
696
|
|
|
669
697
|
// ===== 文件锁持有集合(touches 排他)=====
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
698
|
+
// 口径(用户 2026-10-06 裁决):锁只护「正在写」的阶段——**状态流转到已完成(resolved)即放锁**。
|
|
699
|
+
// 这是对 task-muv7c8ja / e969f57「锁持到归档」的回调:归档应回归纯收纳动作,不再是释放点。
|
|
700
|
+
// 原话:「文件锁归档后才解锁不合理,应该状态自动流转到已完成时解锁」(反馈 n-muupqg81u575 的
|
|
701
|
+
// 真问题——验收后-提交前窗口期污染——改由流程承接,不再用长持锁兜底)。
|
|
702
|
+
// 持锁两态(声明了 touches 为前提):
|
|
673
703
|
// - in-progress + claimedBy:Worker 正在改文件(claimedBy 为空=占位未落座/僵尸,不算持锁);
|
|
674
704
|
// - verifying:Worker 已停笔但**尚未落定**——驳回会回 in-progress 让同一批文件继续被改,
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
705
|
+
// 故锁不能在 in-progress→verifying 断(Verifier 只读,不参与排他)。
|
|
706
|
+
// 即放(不持有):resolved(完成即放,未归档也放)、cancelled(放弃语义=不再产出)、
|
|
707
|
+
// archived(归档只是收纳,对锁零影响)、pending/blocked/draft(没有 Worker 在改文件)。
|
|
708
|
+
// 承接关系(「验收后-提交前」窗口期为什么不再靠锁兜底):
|
|
709
|
+
// ① 主窗口即时门禁纪律:收到完成回执就立即提交,不等不看别的卡(提交时点与回执对齐);
|
|
710
|
+
// ② 史诗 post-hook:提交动作收进任务生命周期(post=done 才收口 epic),提交可追溯、可审计。
|
|
711
|
+
// 锁只表达「文件正在被写」,「改动尚未提交」由上面两条承接——否则 resolved 卡长期不归档
|
|
712
|
+
// 会一直堵住同批文件、把本可并行的批次全串行化,这正是本次回调要消除的代价。
|
|
682
713
|
// 返回 [{id, touches}],id 用于 blockedTouches.conflicts 展示"在等谁"。
|
|
683
714
|
export function holdsFiles(d) {
|
|
684
715
|
var out = []
|
|
@@ -686,7 +717,7 @@ export function holdsFiles(d) {
|
|
|
686
717
|
for (var i = 0; i < d.tasks.length; i++) {
|
|
687
718
|
var t = d.tasks[i]
|
|
688
719
|
if (!t || !Array.isArray(t.touches) || !t.touches.length) continue
|
|
689
|
-
var holds = t.status === 'verifying' ||
|
|
720
|
+
var holds = t.status === 'verifying' || (t.status === 'in-progress' && !!t.claimedBy)
|
|
690
721
|
if (holds) out.push({ id: t.id, touches: t.touches })
|
|
691
722
|
}
|
|
692
723
|
return out
|
|
@@ -822,7 +853,8 @@ export function attachContextSuggestions(out, warnings, touches, exists) {
|
|
|
822
853
|
|
|
823
854
|
// epic 发布预检:父卡 publish 时对其子任务做轻量调研注入预检。
|
|
824
855
|
// 口径:只看 pipeline≠direct 的非归档子任务;「无调研注入」= context.files/context.notes 皆空,
|
|
825
|
-
// 或 files 列了路径但全部不存在(exists 回调判定,锚点 :L
|
|
856
|
+
// 或 files 列了路径但全部不存在(exists 回调判定,锚点 :L 段与可选「 — 一句用途」后缀先剥掉再查——
|
|
857
|
+
// 存在性是对文件而言的,见 parseContextFileEntry)。
|
|
826
858
|
// exists(path) 由调用方注入(fs.existsSync + 会话工作区相对解析包装);缺省时退化为只查字段有无。
|
|
827
859
|
// 返回 { total, missing: [{id, title, reason}] };total=参与预检的子任务数(供汇总文案 N/M)。
|
|
828
860
|
export function epicPrecheck(tasks, parentId, exists) {
|
|
@@ -836,8 +868,8 @@ export function epicPrecheck(tasks, parentId, exists) {
|
|
|
836
868
|
var reason = ''
|
|
837
869
|
if (!files.length && !hasNotes) reason = '无 contextFiles/contextNotes'
|
|
838
870
|
else if (files.length && typeof exists === 'function') {
|
|
839
|
-
var anyExist = files.some(function (p) { try { return !!exists(
|
|
840
|
-
if (!anyExist) reason = '
|
|
871
|
+
var anyExist = files.some(function (p) { try { return !!exists(parseContextFileEntry(p).file) } catch (_) { return false } })
|
|
872
|
+
if (!anyExist) reason = '预研文件路径全部不存在(子代理按需 read 时也会全部读不到)'
|
|
841
873
|
}
|
|
842
874
|
if (reason) missing.push({ id: String(k.id || ''), title: String(k.title || '').slice(0, 40), reason: reason })
|
|
843
875
|
}
|