dsh-agent-board 1.7.2 → 1.7.3
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 +27 -10
- package/index.mjs +10 -4
- package/lib/client/dashboard.js +58 -17
- package/lib/client/kernel.js +44 -6
- package/lib/client/task-detail.js +7 -3
- package/lib/client.js +109 -26
- package/lib/core.mjs +61 -29
- package/lib/dispatch.mjs +920 -226
- package/lib/rpc.mjs +113 -17
- package/lib/store.mjs +26 -1
- package/lib/usage.mjs +160 -20
- package/package.json +1 -1
- package/test/core.test.mjs +1980 -104
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() {
|
|
@@ -1558,12 +1607,29 @@ function apply(ctx) {
|
|
|
1558
1607
|
function TokenUsage(props) {
|
|
1559
1608
|
var u = props.usage
|
|
1560
1609
|
var box = { padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6, marginBottom: 12 }
|
|
1561
|
-
|
|
1610
|
+
// 范围激活标记:Token 区的模型分布/Top8/累计已按「统计范围」过滤(host 侧重算),
|
|
1611
|
+
// 不加标记的话用户没法判断看到的数字是全量还是范围内——标记与 RangeFilter 同源(activeRange)。
|
|
1612
|
+
var tg = activeRange()
|
|
1613
|
+
var tgOn = !!(tg.from || tg.to)
|
|
1614
|
+
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 消耗',
|
|
1615
|
+
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)
|
|
1562
1616
|
if (!u || !u.total) return React.createElement('div', { style: box }, head, React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '暂无数据(Worker/Verifier 会话日志里还没有 usage 记录)'))
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1617
|
+
// ===== 主数字口径统一(task-muwq9u04):模型分布行与 Top8 行的主数字一律显示**有效消耗**(e),
|
|
1618
|
+
// 含缓存读的合计(t)退到 title 悬浮;否则同一块里「今日有效 3.7M」与「单任务 21M」并排自相矛盾
|
|
1619
|
+
// (用户就是这么判成 bug 的)。数据源:host 的 byModelEff(模型有效分摊)与 topTasks[].effective;
|
|
1620
|
+
// 老 host 缺 byModelEff / 老卡无五分量留账 → 退化用合计值并在 title 标 ~(口径不伪造)。
|
|
1621
|
+
// 排序按**显示口径**(有效)降序:条形长度与行序一致,否则首行不是最长的条。
|
|
1622
|
+
var models = Object.keys(u.byModel || {}).map(function (m) {
|
|
1623
|
+
var tt = u.byModel[m] || 0
|
|
1624
|
+
var ee = (u.byModelEff && typeof u.byModelEff[m] === 'number') ? u.byModelEff[m] : null
|
|
1625
|
+
return { model: m, total: tt, eff: (ee === null ? tt : ee), approx: ee === null }
|
|
1626
|
+
}).sort(function (a, b) { return b.eff - a.eff })
|
|
1627
|
+
var maxM = models.length ? (models[0].eff || 1) : 1
|
|
1628
|
+
var top = (u.topTasks || []).map(function (x) {
|
|
1629
|
+
var ee = (typeof x.effective === 'number') ? x.effective : null
|
|
1630
|
+
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 }
|
|
1631
|
+
}).sort(function (a, b) { return b.eff - a.eff })
|
|
1632
|
+
var maxT = top.length ? (top[0].eff || 1) : 1
|
|
1567
1633
|
// 日账:今日数字取本地日 key,没有日账(byDay 缺字段/老 host)时退化为 0,不炸也不误报。
|
|
1568
1634
|
// 读侧兼容两种单元形态:老 number(只有总量,有效值不可知)→ { t: n, e: null }。
|
|
1569
1635
|
var byDay = (u.byDay && typeof u.byDay === 'object') ? u.byDay : {}
|
|
@@ -1609,18 +1675,22 @@ function apply(ctx) {
|
|
|
1609
1675
|
}))) : null,
|
|
1610
1676
|
React.createElement('div', { style: { display: 'flex', gap: 12, flexWrap: 'wrap' } },
|
|
1611
1677
|
React.createElement('div', { style: { flex: '1 1 240px', minWidth: 200 } },
|
|
1612
|
-
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '
|
|
1678
|
+
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '按模型分布(有效消耗)'),
|
|
1613
1679
|
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.
|
|
1680
|
+
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' })
|
|
1615
1681
|
})),
|
|
1616
1682
|
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'),
|
|
1683
|
+
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, marginBottom: 4 } }, '任务消耗 Top 8(有效消耗)'),
|
|
1618
1684
|
top.length === 0 ? React.createElement('div', { style: { fontSize: 10, color: C.text2 } }, '暂无数据') : top.map(function (x) {
|
|
1619
1685
|
var t = getTask(x.id)
|
|
1620
|
-
return React.createElement(UsageRow, { key: x.id, label: x.title || x.id, value: x.
|
|
1686
|
+
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
1687
|
}))),
|
|
1622
1688
|
// 口径边界:本区只统计看板派发的 Worker/Verifier run,主窗口对话自身不越界纳入
|
|
1623
|
-
|
|
1689
|
+
// 范围说明(task-muwc7hjd):用户常把 RangeFilter 当成「整页过滤」,但今日大数字与近 7 天柱子
|
|
1690
|
+
// 是自身固定口径(今日=本地今天、近 7 天=最近 7 个本地日)——不随范围变,必须在文案里讲清,
|
|
1691
|
+
// 否则「选了范围数字没变」看起来像 bug。模型分布/Top8/累计才是范围生效的三处。
|
|
1692
|
+
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 6, lineHeight: 1.5 } }, '口径:仅看板派发的 Worker/Verifier run 消耗,不含主窗口对话;主数字(模型分布 / Top 8)与「今日」「近 7 天」均为有效消耗口径(输入+输出+缓存写,不含缓存读),含缓存读的合计在悬浮 title 里单列对照'),
|
|
1693
|
+
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 2, lineHeight: 1.5 } }, '统计范围作用于按模型分布 / 任务消耗 Top 8 / 累计三分量(按 run 的本地日落点过滤);今日与「近 7 天」为固定口径,不随范围变化。'))
|
|
1624
1694
|
}
|
|
1625
1695
|
|
|
1626
1696
|
// ===== 架构健康提示区(架构自省 L1 · 数据源:state.healthHints)=====
|
|
@@ -1756,6 +1826,19 @@ function apply(ctx) {
|
|
|
1756
1826
|
var _a = useState(false), open = _a[0], setOpen = _a[1]
|
|
1757
1827
|
var _p = useState(null), pos = _p[0], setPos = _p[1]
|
|
1758
1828
|
var ref = useRef(null); var btnRef = useRef(null)
|
|
1829
|
+
// 开关点击即时反馈(反馈:勾选几秒才同步,task-muw5uudk):
|
|
1830
|
+
// checked 是受控值(读 Props→state,由 3s 轮询通知才更新),此前 onChange 只发 rpc →
|
|
1831
|
+
// 点下去要等 set-board-config + fetchTasks 双往返(安静板卡上 tasksHash 不变还会被渲染节约吃掉)才翻面。
|
|
1832
|
+
// 口径:**先写 state + notify(点击瞬时翻面)→ 再 rpc('set-board-config') → 失败回滚 state + notify
|
|
1833
|
+
// 并复用错误条 reportReadErr(沿用「读路径错误防线」:绝不让开关停在未落盘的值上)**。
|
|
1834
|
+
// 成功路径的权威纠偏在 kernel.fetchTasks(配置变化检测),此处不重复 fetchTasks:3s 轮询自会带回真值。
|
|
1835
|
+
function cfgRollback(key, prev) { state[key] = prev; notify() }
|
|
1836
|
+
function setCfg(key, next) {
|
|
1837
|
+
var prev = !!state[key] // 乐观更新前的值,失败回滚用
|
|
1838
|
+
state[key] = next === true // 归一成布尔:默认开口径由服务端权威值(fetchTasks)纠偏
|
|
1839
|
+
notify() // 立即翻面,不等 rpc
|
|
1840
|
+
rpc('set-board-config', { key: key, value: next === true }).catch(function (e) { cfgRollback(key, prev); reportReadErr('设置保存失败:' + readErrText(e)) })
|
|
1841
|
+
}
|
|
1759
1842
|
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
1843
|
function toggle() {
|
|
1761
1844
|
// position:fixed + 视口坐标:面板容器是 overflow:hidden + maxHeight:60vh,
|
|
@@ -1784,16 +1867,16 @@ function apply(ctx) {
|
|
|
1784
1867
|
// 学习飞轮 v1 总开关:关掉后不生成候选教训、prompt 不提软召回、详情页不渲染「沉淀」按钮
|
|
1785
1868
|
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '学习反馈'),
|
|
1786
1869
|
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) {
|
|
1870
|
+
React.createElement('input', { type: 'checkbox', checked: !!props.feedbackEnabled, onChange: function (e) { setCfg('feedbackEnabled', e.target.checked) } }),
|
|
1788
1871
|
React.createElement('span', null, '候选教训(Verifier 驳回/仲裁结论自动生成候选,Worker prompt 提示先检索历史教训)')),
|
|
1789
1872
|
// 回执开关(用户要求「回执可以做一个开关,放到设置里」):派发/完成两类回执各自可关,缺省都开。
|
|
1790
1873
|
// 只影响回执播报,不影响派发与状态机;歧义裁决通知不在此闸门内(见下行说明文案)。
|
|
1791
1874
|
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '通知'),
|
|
1792
1875
|
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) {
|
|
1876
|
+
React.createElement('input', { type: 'checkbox', checked: props.notifyDispatch !== false, onChange: function (e) { setCfg('notifyDispatch', e.target.checked) } }),
|
|
1794
1877
|
React.createElement('span', null, '⚡ 派发回执(任务被 Worker/Verifier 领走时播报)')),
|
|
1795
1878
|
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) {
|
|
1879
|
+
React.createElement('input', { type: 'checkbox', checked: props.notifyDone !== false, onChange: function (e) { setCfg('notifyDone', e.target.checked) } }),
|
|
1797
1880
|
React.createElement('span', null, '✅ 完成回执(任务完成或阻塞时聚合播报)')),
|
|
1798
1881
|
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 3, whiteSpace: 'normal', maxWidth: 260 } }, '歧义裁决通知不受这两个开关影响(任务等人裁决必须提醒)'),
|
|
1799
1882
|
// 史诗拆分总开关(板级 epicSplit,缺省 true):**只关引导,不禁机制**——关掉后 Team 提示词不再
|
|
@@ -1801,7 +1884,7 @@ function apply(ctx) {
|
|
|
1801
1884
|
// 子卡、史诗自动收口/hooks 状态机照常(用户/主窗口明确要拆时不受阻)。勾选态缺字段=开,与 host 同口径。
|
|
1802
1885
|
React.createElement('div', { style: { fontSize: 10, fontWeight: 600, color: C.text2, margin: '7px 0 5px' } }, '功能'),
|
|
1803
1886
|
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) {
|
|
1887
|
+
React.createElement('input', { type: 'checkbox', checked: props.epicSplit !== false, onChange: function (e) { setCfg('epicSplit', e.target.checked) } }),
|
|
1805
1888
|
React.createElement('span', null, '🧩 史诗拆分:大任务引导拆为 epic + 子任务')),
|
|
1806
1889
|
React.createElement('div', { style: { fontSize: 9, color: C.text2, marginTop: 3, whiteSpace: 'normal', maxWidth: 260 } }, '关掉只停引导:显式 parentId 建子卡与史诗自动收口照常工作')) : null)
|
|
1807
1890
|
}
|
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
|
}
|