dsh-agent-board 1.6.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/lib/client/dashboard.js +33 -0
- package/lib/client.js +33 -0
- package/lib/core.mjs +1 -1
- package/lib/health.mjs +130 -0
- package/lib/rpc.mjs +3 -0
- package/package.json +2 -2
- package/test/core.test.mjs +14 -0
- package/test/health.test.mjs +144 -0
package/README.md
CHANGED
|
@@ -101,6 +101,7 @@ dsh plugin --profile web remove dsh-agent-board
|
|
|
101
101
|
|
|
102
102
|
- 仪表盘视图新增「Token 消耗」区:本看板累计总量 + 输入 / 输出 / 缓存读(缓存写非零时一并展示)拆分、按模型分布条形图、任务消耗 **Top 8**(标题可点击直达该任务详情);进行中的卡片右上角显示本任务已累计消耗(`⛁ 数字`)
|
|
103
103
|
- 数据来源:每次 Worker/Verifier run 结算时读该 run 的 v4 会话日志(`~/.dsh/sessions/*/<runId>/session.v4.jsonl.zstd`),把 `assistant/message` 事件的 `usage`(`inputTokens` / `outputTokens` / `cacheReadTokens` / `cacheWriteTokens` / `totalTokens`,字段形状以真实日志为准)按 zstd 帧逐帧累加到任务 `usage`(含按模型小计与 `runs` 计数,多轮重跑/驳回重做自动累加),`get-tasks` 再现算 board 级 `usageSummary`(总量 / 按模型 / Top8,不落盘额外表)——**只做展示、不做计费断言**,日志读不到或没有 usage 时一律显示「暂无数据」
|
|
104
|
+
- 「架构健康」区(架构自省 L1):`get-tasks` 顺带对**近 50 张卡**现算四信号(纯函数零存储:touches 声明热度 ≥8 次且占比 ≥40% / 带 touches 任务滞留中位数 >2 倍 / 任务时长 p90 >45min / 同路径驳回 ≥2 次),命中才在仪表盘渲染提示条(⚠️/ℹ️ 两级,最多 3 条)——让运行数据主动提示"该优化架构了"(如某文件反复成为锁热点=该拆),信号只建议不裁判
|
|
104
105
|
|
|
105
106
|
### 学习反馈(候选教训信号 → 主窗口沉淀)
|
|
106
107
|
|
package/lib/client/dashboard.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// ============================================================================
|
|
2
2
|
// dashboard —— 仪表盘域:统计(computeStats/TrendChart/StatCard/BarRow)· 范围筛选(RangeFilter)
|
|
3
3
|
// · 全局总览(GlobalBoards)· 报告(buildReport/ReportButton)· Token 消耗(TokenUsage)
|
|
4
|
+
// · 架构健康(HealthHints,架构自省 L1)
|
|
4
5
|
// · 团队池(TeamView/PoolStatus)· 设置(WorkModeSwitch/PoolCfg/MinCfg/ModelCfg/PoolCfgPopover)
|
|
5
6
|
// 本文件是 apply(ctx) 函数体片段,不可独立运行;由 scripts/build-client.cjs 按序拼接生成 lib/client.js。
|
|
6
7
|
// ============================================================================
|
|
@@ -204,12 +205,44 @@
|
|
|
204
205
|
}))))
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
// ===== 架构健康提示区(架构自省 L1 · 数据源:host 端 get-tasks 的 healthHints)=====
|
|
209
|
+
// healthHints 由 host lib/health.mjs 的 computeHealthHints(tasks) 每次请求现算(纯函数零存储零 IO):
|
|
210
|
+
// [{ level: 'warn'|'info', text }]。缺省兼容:老 host 无此字段 / 非数组 / 空数组 → 返回 null,
|
|
211
|
+
// 整块不渲染(零残留)。无按钮无状态(不做 dismiss):以 state.tasks 为 effect 依赖——kernel
|
|
212
|
+
// fetchTasks 每轮都赋它新数组引用,故本区随 3s 轮询同步重取,只反映 host 最新一轮纯重算结果。
|
|
213
|
+
function HealthHints() {
|
|
214
|
+
var _R = React; var useState = _R.useState, useEffect = _R.useEffect
|
|
215
|
+
var _a = useState([]), hints = _a[0], setHints = _a[1]
|
|
216
|
+
useEffect(function () {
|
|
217
|
+
var dead = false
|
|
218
|
+
rpc('get-tasks').then(function (d) {
|
|
219
|
+
if (dead) return
|
|
220
|
+
var arr = (d && Array.isArray(d.healthHints)) ? d.healthHints : []
|
|
221
|
+
setHints(arr.filter(function (h) { return h && h.text }))
|
|
222
|
+
}).catch(function () {})
|
|
223
|
+
return function () { dead = true }
|
|
224
|
+
}, [state.tasks])
|
|
225
|
+
if (!hints.length) return null
|
|
226
|
+
// 两级配色:warn=⚠️ 琥珀(C.warn);info=ℹ️ 蓝灰(DSH 现有 business 信息色——色板无独立 info 档)
|
|
227
|
+
var infoColor = 'var(--dsw-alias-state-business-primary)'
|
|
228
|
+
return React.createElement('div', { style: { padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6, marginBottom: 12 } },
|
|
229
|
+
React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 4 } }, ic('activity', 11), '架构健康'),
|
|
230
|
+
hints.map(function (h, i) {
|
|
231
|
+
var warn = h.level === 'warn'
|
|
232
|
+
var col = warn ? C.warn : infoColor
|
|
233
|
+
return React.createElement('div', { key: i, style: { display: 'flex', alignItems: 'baseline', gap: 5, fontSize: 10, lineHeight: 1.5, color: col, marginBottom: 3 } },
|
|
234
|
+
React.createElement('span', { style: { flexShrink: 0 } }, warn ? '⚠️' : 'ℹ️'),
|
|
235
|
+
React.createElement('span', null, String(h.text)))
|
|
236
|
+
}))
|
|
237
|
+
}
|
|
238
|
+
|
|
207
239
|
function Dashboard() {
|
|
208
240
|
var stats = computeStats(state.tasks); var statusOrder = ['pending', 'in-progress', 'verifying', 'resolved', 'blocked', 'archived']; var prioOrder = ['critical', 'high', 'medium', 'low']
|
|
209
241
|
return React.createElement('div', null,
|
|
210
242
|
React.createElement(RangeFilter),
|
|
211
243
|
React.createElement(GlobalBoards),
|
|
212
244
|
React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' } }, React.createElement(StatCard, { label: '总任务', value: stats.total, color: C.text }), React.createElement(StatCard, { label: '待办', value: stats.byStatus['pending'] || 0, color: C.text2 }), React.createElement(StatCard, { label: '进行中', value: stats.byStatus['in-progress'] || 0, color: C.brand }), React.createElement(StatCard, { label: '验证中', value: stats.byStatus['verifying'] || 0, color: C.warn }), React.createElement(StatCard, { label: '已完成', value: stats.byStatus['resolved'] || 0, color: C.ok }), React.createElement(StatCard, { label: '已归档', value: stats.byStatus['archived'] || 0, color: C.text2 })),
|
|
245
|
+
React.createElement(HealthHints),
|
|
213
246
|
React.createElement(TokenUsage, { usage: state.usageSummary }),
|
|
214
247
|
React.createElement('div', { style: { display: 'flex', gap: 12, marginBottom: 12, flexWrap: 'wrap' } },
|
|
215
248
|
React.createElement('div', { style: { flex: '1 1 0', minWidth: 200, padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6 } }, React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6 } }, '按状态分布'), statusOrder.map(function (s) { return React.createElement(BarRow, { key: s, label: statusLabels[s] || s, count: stats.byStatus[s] || 0, total: stats.total, color: statusColors[s] || C.brand }) })),
|
package/lib/client.js
CHANGED
|
@@ -900,6 +900,7 @@ function apply(ctx) {
|
|
|
900
900
|
// ============================================================================
|
|
901
901
|
// dashboard —— 仪表盘域:统计(computeStats/TrendChart/StatCard/BarRow)· 范围筛选(RangeFilter)
|
|
902
902
|
// · 全局总览(GlobalBoards)· 报告(buildReport/ReportButton)· Token 消耗(TokenUsage)
|
|
903
|
+
// · 架构健康(HealthHints,架构自省 L1)
|
|
903
904
|
// · 团队池(TeamView/PoolStatus)· 设置(WorkModeSwitch/PoolCfg/MinCfg/ModelCfg/PoolCfgPopover)
|
|
904
905
|
// 本文件是 apply(ctx) 函数体片段,不可独立运行;由 scripts/build-client.cjs 按序拼接生成 lib/client.js。
|
|
905
906
|
// ============================================================================
|
|
@@ -1103,12 +1104,44 @@ function apply(ctx) {
|
|
|
1103
1104
|
}))))
|
|
1104
1105
|
}
|
|
1105
1106
|
|
|
1107
|
+
// ===== 架构健康提示区(架构自省 L1 · 数据源:host 端 get-tasks 的 healthHints)=====
|
|
1108
|
+
// healthHints 由 host lib/health.mjs 的 computeHealthHints(tasks) 每次请求现算(纯函数零存储零 IO):
|
|
1109
|
+
// [{ level: 'warn'|'info', text }]。缺省兼容:老 host 无此字段 / 非数组 / 空数组 → 返回 null,
|
|
1110
|
+
// 整块不渲染(零残留)。无按钮无状态(不做 dismiss):以 state.tasks 为 effect 依赖——kernel
|
|
1111
|
+
// fetchTasks 每轮都赋它新数组引用,故本区随 3s 轮询同步重取,只反映 host 最新一轮纯重算结果。
|
|
1112
|
+
function HealthHints() {
|
|
1113
|
+
var _R = React; var useState = _R.useState, useEffect = _R.useEffect
|
|
1114
|
+
var _a = useState([]), hints = _a[0], setHints = _a[1]
|
|
1115
|
+
useEffect(function () {
|
|
1116
|
+
var dead = false
|
|
1117
|
+
rpc('get-tasks').then(function (d) {
|
|
1118
|
+
if (dead) return
|
|
1119
|
+
var arr = (d && Array.isArray(d.healthHints)) ? d.healthHints : []
|
|
1120
|
+
setHints(arr.filter(function (h) { return h && h.text }))
|
|
1121
|
+
}).catch(function () {})
|
|
1122
|
+
return function () { dead = true }
|
|
1123
|
+
}, [state.tasks])
|
|
1124
|
+
if (!hints.length) return null
|
|
1125
|
+
// 两级配色:warn=⚠️ 琥珀(C.warn);info=ℹ️ 蓝灰(DSH 现有 business 信息色——色板无独立 info 档)
|
|
1126
|
+
var infoColor = 'var(--dsw-alias-state-business-primary)'
|
|
1127
|
+
return React.createElement('div', { style: { padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6, marginBottom: 12 } },
|
|
1128
|
+
React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 4 } }, ic('activity', 11), '架构健康'),
|
|
1129
|
+
hints.map(function (h, i) {
|
|
1130
|
+
var warn = h.level === 'warn'
|
|
1131
|
+
var col = warn ? C.warn : infoColor
|
|
1132
|
+
return React.createElement('div', { key: i, style: { display: 'flex', alignItems: 'baseline', gap: 5, fontSize: 10, lineHeight: 1.5, color: col, marginBottom: 3 } },
|
|
1133
|
+
React.createElement('span', { style: { flexShrink: 0 } }, warn ? '⚠️' : 'ℹ️'),
|
|
1134
|
+
React.createElement('span', null, String(h.text)))
|
|
1135
|
+
}))
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1106
1138
|
function Dashboard() {
|
|
1107
1139
|
var stats = computeStats(state.tasks); var statusOrder = ['pending', 'in-progress', 'verifying', 'resolved', 'blocked', 'archived']; var prioOrder = ['critical', 'high', 'medium', 'low']
|
|
1108
1140
|
return React.createElement('div', null,
|
|
1109
1141
|
React.createElement(RangeFilter),
|
|
1110
1142
|
React.createElement(GlobalBoards),
|
|
1111
1143
|
React.createElement('div', { style: { display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' } }, React.createElement(StatCard, { label: '总任务', value: stats.total, color: C.text }), React.createElement(StatCard, { label: '待办', value: stats.byStatus['pending'] || 0, color: C.text2 }), React.createElement(StatCard, { label: '进行中', value: stats.byStatus['in-progress'] || 0, color: C.brand }), React.createElement(StatCard, { label: '验证中', value: stats.byStatus['verifying'] || 0, color: C.warn }), React.createElement(StatCard, { label: '已完成', value: stats.byStatus['resolved'] || 0, color: C.ok }), React.createElement(StatCard, { label: '已归档', value: stats.byStatus['archived'] || 0, color: C.text2 })),
|
|
1144
|
+
React.createElement(HealthHints),
|
|
1112
1145
|
React.createElement(TokenUsage, { usage: state.usageSummary }),
|
|
1113
1146
|
React.createElement('div', { style: { display: 'flex', gap: 12, marginBottom: 12, flexWrap: 'wrap' } },
|
|
1114
1147
|
React.createElement('div', { style: { flex: '1 1 0', minWidth: 200, padding: '8px 10px', background: C.card, border: '1px solid ' + C.border, borderRadius: 6 } }, React.createElement('div', { style: { fontSize: 11, fontWeight: 600, color: C.text2, marginBottom: 6 } }, '按状态分布'), statusOrder.map(function (s) { return React.createElement(BarRow, { key: s, label: statusLabels[s] || s, count: stats.byStatus[s] || 0, total: stats.total, color: statusColors[s] || C.brand }) })),
|
package/lib/core.mjs
CHANGED
|
@@ -32,7 +32,7 @@ export function vt(d) { return d && typeof d === 'object' && Array.isArray(d.tas
|
|
|
32
32
|
// 5. 裸文件名(无 '/' 且非 '*.ext')只在「另一侧也是裸名」时比 basename 相等;
|
|
33
33
|
// 含 '/' 的具体路径不参与裸名规则(否则 'a/core.mjs' 会误撞裸名 'core.mjs')。
|
|
34
34
|
// 保守倾向:不确定即算冲突(误拦只是晚一轮派发,漏拦会让两个 Worker 互踩)。
|
|
35
|
-
function normTouch(s) {
|
|
35
|
+
export function normTouch(s) {
|
|
36
36
|
var v = String(s).replace(/\\/g, '/').trim()
|
|
37
37
|
while (v.slice(0, 2) === './') v = v.slice(2)
|
|
38
38
|
while (v.length > 1 && v.charAt(v.length - 1) === '/') v = v.slice(0, -1)
|
package/lib/health.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// dsh-agent-board — 架构自省 L1(lib/health.mjs)
|
|
2
|
+
// 纯函数、零状态、零 IO:从任务数组现算「架构健康提示」,不落盘、不通知、不依赖任何 ctx。
|
|
3
|
+
// 数据源只用任务持久化字段(createdAt/claimedAt/resolvedAt/touches/rejectCount)。
|
|
4
|
+
// 窗口化(近 50 张卡)而非累计:架构优化落地后老数据自动滑出窗口不再报警——
|
|
5
|
+
// 健康区同时是「拆分是否有效」的客观验收证据。
|
|
6
|
+
// 信号源不做裁判:只产提示文案,决策永远是人。
|
|
7
|
+
import { normTouch } from './core.mjs'
|
|
8
|
+
|
|
9
|
+
// ===== 阈值常量(全部置顶,可按实际使用手感调整;只影响之后的计算,不追溯老数据)=====
|
|
10
|
+
var WINDOW_SIZE = 50 // 统计窗口:按 createdAt 取最近 N 张卡(含归档)
|
|
11
|
+
var TOUCH_HOT_MIN = 8 // 信号 a:单路径 touches 声明次数阈值
|
|
12
|
+
var TOUCH_HOT_RATIO = 0.4 // 信号 a:声明次数占窗口内「有 touches 任务」的比例阈值
|
|
13
|
+
var SERIAL_MIN_TOUCH = 5 // 信号 b:有 touches 任务的最小样本数(不足则不判定,防小样本噪声)
|
|
14
|
+
var SERIAL_RATIO = 2 // 信号 b:有 touches 任务滞留中位数 > 无 touches 任务的 N 倍
|
|
15
|
+
var P90_LIMIT_MIN = 45 // 信号 c:resolved 任务时长 p90 阈值(分钟)
|
|
16
|
+
var REJECT_HOT_MIN = 2 // 信号 d:单路径累计驳回次数阈值
|
|
17
|
+
var MAX_HINTS = 3 // 返回提示上限(warn 信号优先排列)
|
|
18
|
+
|
|
19
|
+
// 毫秒 → 分钟(四舍五入取整,仅供文案展示)
|
|
20
|
+
function toMin(ms) { return Math.round(ms / 60000) }
|
|
21
|
+
|
|
22
|
+
// 中位数(输入任意序,内部复制排序;空数组返回 null)
|
|
23
|
+
function medianOf(nums) {
|
|
24
|
+
if (!nums.length) return null
|
|
25
|
+
var v = nums.slice().sort(function (a, b) { return a - b })
|
|
26
|
+
var n = v.length
|
|
27
|
+
return n % 2 ? v[(n - 1) / 2] : (v[n / 2 - 1] + v[n / 2]) / 2
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// p 分位数(near-rank:升序后取 ceil(p*n)-1 位;空数组返回 null)
|
|
31
|
+
function percentileOf(nums, p) {
|
|
32
|
+
if (!nums.length) return null
|
|
33
|
+
var v = nums.slice().sort(function (a, b) { return a - b })
|
|
34
|
+
var i = Math.ceil(p * v.length) - 1
|
|
35
|
+
if (i < 0) i = 0
|
|
36
|
+
return v[i]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// pending 滞留代理:createdAt →(claimedAt 优先,否则 resolvedAt)。无法计算返回 -1。
|
|
40
|
+
function waitMsOf(t) {
|
|
41
|
+
var end = t.claimedAt || t.resolvedAt
|
|
42
|
+
if (!end || !t.createdAt) return -1
|
|
43
|
+
var ms = Date.parse(end) - Date.parse(t.createdAt)
|
|
44
|
+
return (isNaN(ms) || ms < 0) ? -1 : ms
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 任务时长:resolvedAt - createdAt。无法计算返回 -1。
|
|
48
|
+
function durationMsOf(t) {
|
|
49
|
+
if (!t.resolvedAt || !t.createdAt) return -1
|
|
50
|
+
var ms = Date.parse(t.resolvedAt) - Date.parse(t.createdAt)
|
|
51
|
+
return (isNaN(ms) || ms < 0) ? -1 : ms
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 任务 touches 归一化(与 core.mjs normTouch 同款口径)+ 单任务内去重;无 touches 返回 []
|
|
55
|
+
function normPathsOf(t) {
|
|
56
|
+
var raw = Array.isArray(t.touches) ? t.touches : []
|
|
57
|
+
var seen = {}, out = []
|
|
58
|
+
for (var i = 0; i < raw.length; i++) {
|
|
59
|
+
if (typeof raw[i] !== 'string') continue
|
|
60
|
+
var p = normTouch(raw[i])
|
|
61
|
+
if (p && !seen[p]) { seen[p] = true; out.push(p) }
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 取计数最高的路径;并列时取字典序较小者保证确定性。返回 [path, count],空表返回 ['', 0]
|
|
67
|
+
function topPath(counts) {
|
|
68
|
+
var best = '', bestN = 0
|
|
69
|
+
for (var p in counts) {
|
|
70
|
+
var n = counts[p]
|
|
71
|
+
if (n > bestN || (n === bestN && n > 0 && p < best)) { best = p; bestN = n }
|
|
72
|
+
}
|
|
73
|
+
return [best, bestN]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 主入口:tasks = 看板任务数组(含归档)。返回 [{level:'warn'|'info', text}],最多 MAX_HINTS 条,无命中返回 []。
|
|
77
|
+
export function computeHealthHints(tasks) {
|
|
78
|
+
var all = Array.isArray(tasks) ? tasks.slice() : []
|
|
79
|
+
if (!all.length) return []
|
|
80
|
+
// 窗口:按 createdAt 倒序取最近 WINDOW_SIZE 张(缺 createdAt 视为空串,排在最老一侧)
|
|
81
|
+
all.sort(function (a, b) { return String((b && b.createdAt) || '').localeCompare(String((a && a.createdAt) || '')) })
|
|
82
|
+
var win = all.slice(0, WINDOW_SIZE)
|
|
83
|
+
|
|
84
|
+
// 单趟遍历收集各信号所需的分组数据
|
|
85
|
+
var declCounts = {} // 信号 a:路径 → 声明次数
|
|
86
|
+
var rejCounts = {} // 信号 d:路径 → 累计驳回次数
|
|
87
|
+
var waitWith = [] // 信号 b:有 touches 任务的滞留毫秒
|
|
88
|
+
var waitWithout = [] // 信号 b:无 touches 任务的滞留毫秒
|
|
89
|
+
var withTouchN = 0 // 窗口内有 touches 的任务数(信号 a 占比分母)
|
|
90
|
+
var durations = [] // 信号 c:resolved 任务时长毫秒
|
|
91
|
+
for (var i = 0; i < win.length; i++) {
|
|
92
|
+
var t = win[i] || {}
|
|
93
|
+
var paths = normPathsOf(t)
|
|
94
|
+
if (paths.length) {
|
|
95
|
+
withTouchN++
|
|
96
|
+
for (var j = 0; j < paths.length; j++) declCounts[paths[j]] = (declCounts[paths[j]] || 0) + 1
|
|
97
|
+
}
|
|
98
|
+
var rc = t.rejectCount || 0
|
|
99
|
+
if (rc > 0) { for (var k = 0; k < paths.length; k++) rejCounts[paths[k]] = (rejCounts[paths[k]] || 0) + rc }
|
|
100
|
+
var w = waitMsOf(t)
|
|
101
|
+
if (w >= 0) { if (paths.length) waitWith.push(w); else waitWithout.push(w) }
|
|
102
|
+
var dur = durationMsOf(t)
|
|
103
|
+
if (dur >= 0) durations.push(dur)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
var hints = []
|
|
107
|
+
// --- 信号 a:touches 声明热度(某路径被高频声明 = 架构收敛点失控,该拆)---
|
|
108
|
+
var hot = topPath(declCounts)
|
|
109
|
+
if (hot[1] >= TOUCH_HOT_MIN && withTouchN > 0 && hot[1] / withTouchN >= TOUCH_HOT_RATIO) {
|
|
110
|
+
hints.push({ level: 'warn', text: hot[0] + ' 在近 ' + win.length + ' 张卡中被 ' + hot[1] + ' 次声明 touches(占比 ' + Math.round(hot[1] / withTouchN * 100) + '%)——考虑拆分模块' })
|
|
111
|
+
}
|
|
112
|
+
// --- 信号 d:驳回热点(按 touches 路径聚合 rejectCount = 质量脆弱区)---
|
|
113
|
+
var rej = topPath(rejCounts)
|
|
114
|
+
if (rej[1] >= REJECT_HOT_MIN) {
|
|
115
|
+
hints.push({ level: 'warn', text: rej[0] + ' 相关任务被驳回 ' + rej[1] + ' 次——质量脆弱区,建议审查该模块' })
|
|
116
|
+
}
|
|
117
|
+
// --- 信号 b:串行代价代理(有 touches 任务滞留中位数显著更长 = 并行度受锁限制)---
|
|
118
|
+
if (waitWith.length >= SERIAL_MIN_TOUCH && waitWithout.length > 0) {
|
|
119
|
+
var medW = medianOf(waitWith), medWo = medianOf(waitWithout)
|
|
120
|
+
if (medW > medWo * SERIAL_RATIO) {
|
|
121
|
+
hints.push({ level: 'info', text: '带 touches 任务平均等待明显更长(中位数 ' + toMin(medW) + 'min vs ' + toMin(medWo) + 'min),并行度受锁限制' })
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// --- 信号 c:时长 p90(resolved 任务时长尾部抬升 = 粒度/架构成本恶化)---
|
|
125
|
+
var p90 = percentileOf(durations, 0.9)
|
|
126
|
+
if (p90 !== null && p90 > P90_LIMIT_MIN * 60000) {
|
|
127
|
+
hints.push({ level: 'info', text: '任务时长 p90 已达 ' + toMin(p90) + 'min——超长任务占比升高,考虑拆分粒度或检查架构热点' })
|
|
128
|
+
}
|
|
129
|
+
return hints.slice(0, MAX_HINTS)
|
|
130
|
+
}
|
package/lib/rpc.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import fsNode from 'node:fs'
|
|
|
11
11
|
import { findRunLog, readLogBytes, readLogFrames, aggregateUsageSummary } from './usage.mjs'
|
|
12
12
|
import { TASK_SIZE_CONTRACT, withSplitHint, pushRejectLesson, pushArbitrationLesson } from './policy.mjs'
|
|
13
13
|
import { makeMsg } from './notify.mjs'
|
|
14
|
+
import { computeHealthHints } from './health.mjs'
|
|
14
15
|
const { ah, isb, gsb, gpt, vt, validateDeps, classifyPipeline, cfg, claimCheck, claimApply, resolveApply, verifyApply, PRIO_RANK, touchesConflict, holdsFiles, boardHome } = core
|
|
15
16
|
|
|
16
17
|
function defineTool(options) {
|
|
@@ -71,6 +72,8 @@ export function createRpc(ctx, state, deps) {
|
|
|
71
72
|
d.workMode = deriveWorkMode(d)
|
|
72
73
|
// ===== board 级 token 消耗聚合(现算,不落盘):总量 + 输入/输出/缓存读 + 按模型 + 任务 Top8 =====
|
|
73
74
|
d.usageSummary = aggregateUsageSummary(d.tasks)
|
|
75
|
+
// 架构自省 L1:healthHints 现算(纯函数零存储零 IO,近 50 卡窗口),客户端「架构健康」区超阈值才显示
|
|
76
|
+
d.healthHints = computeHealthHints(d.tasks)
|
|
74
77
|
return d })
|
|
75
78
|
// 派发前上下文预览:主 agent 用它确认"我将注入给子代理的材料"是否足够(不发任务、不落盘)
|
|
76
79
|
handle('preview-context', async function (args) {
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agent-board",
|
|
3
3
|
"description": "DeepSeek Harness 智能看板:任务卡片 + 一次性 Worker/Verifier 派发 + 依赖调度 + 管线分档 + Team 模式(歧义上报主窗口裁决)",
|
|
4
|
-
"version": "1.6.
|
|
4
|
+
"version": "1.6.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"build-client": "node scripts/build-client.cjs",
|
|
9
|
-
"test": "node --test test
|
|
9
|
+
"test": "node --test test/*.test.mjs",
|
|
10
10
|
"pretest": "npm run build-client",
|
|
11
11
|
"sync-readme": "node -e \"require('fs').copyFileSync('../../README.md','README.md')\"",
|
|
12
12
|
"prepublishOnly": "npm run build-client && node --check index.mjs && node --check lib/client.js && node --check lib/core.mjs && npm test",
|
package/test/core.test.mjs
CHANGED
|
@@ -864,3 +864,17 @@ test('学习飞轮接线:两处触发点 + push-lesson 开关拦截 + prompt/
|
|
|
864
864
|
assert.doesNotMatch(coreSrc, /from 'dsh-notes|ctx\.get\('notes'\)|uiWorkspace\.openNote|note_manage\(|note_search\(/)
|
|
865
865
|
assert.doesNotMatch(cli, /from 'dsh-notes|ctx\.get\('notes'\)|uiWorkspace\.openNote|note_manage\(|note_search\(/)
|
|
866
866
|
})
|
|
867
|
+
|
|
868
|
+
// ===== 架构自省 L1·UI:仪表盘「架构健康」提示区接线(源码级断言;纯函数信号本身见 health.test.mjs)=====
|
|
869
|
+
test('架构健康接线:get-tasks 返回 healthHints + 仪表盘 HealthHints 区缺省兼容渲染', () => {
|
|
870
|
+
const host = hostSrc()
|
|
871
|
+
const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
872
|
+
assert.match(host, /d\.healthHints = computeHealthHints\(d\.tasks\)/) // host 每次请求现算返回(零存储)
|
|
873
|
+
assert.match(cli, /function HealthHints\(\)/) // 仪表盘提示区组件
|
|
874
|
+
assert.match(cli, /React\.createElement\(HealthHints\)/) // 挂进 Dashboard(统计卡行下方、Token 消耗区上方)
|
|
875
|
+
assert.match(cli, /Array\.isArray\(d\.healthHints\)/) // 缺省兼容:老 host 无字段/非数组 → 按空处理
|
|
876
|
+
assert.match(cli, /if \(!hints\.length\) return null/) // 空数组 → 整块不渲染(零残留)
|
|
877
|
+
assert.match(cli, /'架构健康'/) // 区标题
|
|
878
|
+
assert.match(cli, /warn \? C\.warn : infoColor/) // warn=琥珀 / info=蓝灰 两级配色
|
|
879
|
+
assert.match(cli, /dsw-alias-state-business-primary/) // info 蓝灰用 DSH 现有 business 信息色(色板无独立 info 档)
|
|
880
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// dsh-agent-board — 架构自省 L1 单测(test/health.test.mjs)
|
|
2
|
+
// 覆盖:四信号命中/不命中、窗口边界(近 50 张卡)、空板、提示上限 3 条。
|
|
3
|
+
import { test } from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { computeHealthHints } from '../lib/health.mjs'
|
|
6
|
+
|
|
7
|
+
var MIN = 60000
|
|
8
|
+
var BASE = Date.parse('2026-01-01T00:00:00.000Z')
|
|
9
|
+
function iso(ms) { return new Date(ms).toISOString() }
|
|
10
|
+
|
|
11
|
+
// 造卡:i 决定 createdAt(i 越大越新),extra 覆盖字段
|
|
12
|
+
function mk(i, extra) {
|
|
13
|
+
var t = { id: 't' + i, createdAt: iso(BASE + i * MIN), status: 'resolved', touches: [], claimedAt: null, resolvedAt: null, rejectCount: 0 }
|
|
14
|
+
return Object.assign(t, extra || {})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ===== 空板 / 非法输入 =====
|
|
18
|
+
test('空板与非法输入返回 []', function () {
|
|
19
|
+
assert.deepEqual(computeHealthHints([]), [])
|
|
20
|
+
assert.deepEqual(computeHealthHints(undefined), [])
|
|
21
|
+
assert.deepEqual(computeHealthHints(null), [])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// ===== 信号 a:touches 声明热度 =====
|
|
25
|
+
test('信号 a 命中:单路径 ≥8 次且占比 ≥40% → warn', function () {
|
|
26
|
+
var ts = []
|
|
27
|
+
for (var i = 0; i < 10; i++) ts.push(mk(i, { touches: ['src/big.js'], claimedAt: iso(BASE + i * MIN + 1 * MIN), resolvedAt: iso(BASE + i * MIN + 2 * MIN) }))
|
|
28
|
+
var hints = computeHealthHints(ts)
|
|
29
|
+
assert.equal(hints.length, 1)
|
|
30
|
+
assert.equal(hints[0].level, 'warn')
|
|
31
|
+
assert.ok(hints[0].text.indexOf('src/big.js') >= 0)
|
|
32
|
+
assert.ok(hints[0].text.indexOf('10 次声明 touches') >= 0)
|
|
33
|
+
assert.ok(hints[0].text.indexOf('占比 100%') >= 0)
|
|
34
|
+
assert.ok(hints[0].text.indexOf('考虑拆分模块') >= 0)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('信号 a 不命中:声明次数 <8', function () {
|
|
38
|
+
var ts = []
|
|
39
|
+
for (var i = 0; i < 7; i++) ts.push(mk(i, { touches: ['src/big.js'], claimedAt: iso(BASE + i * MIN + MIN), resolvedAt: iso(BASE + i * MIN + 2 * MIN) }))
|
|
40
|
+
assert.deepEqual(computeHealthHints(ts), [])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('信号 a 不命中:次数够但占有 touches 任务比例 <40%', function () {
|
|
44
|
+
var ts = []
|
|
45
|
+
for (var i = 0; i < 8; i++) ts.push(mk(i, { touches: ['src/hot.js'] }))
|
|
46
|
+
for (var j = 0; j < 20; j++) ts.push(mk(100 + j, { touches: ['src/u' + j + '.js'] }))
|
|
47
|
+
// src/hot.js 计 8 次 ≥8,但有 touches 任务共 28 张,占比 28.6% <40% → 不提示
|
|
48
|
+
assert.deepEqual(computeHealthHints(ts), [])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('信号 a 口径:路径归一化(./ 前缀与反斜杠合并计数)', function () {
|
|
52
|
+
var ts = []
|
|
53
|
+
for (var i = 0; i < 4; i++) ts.push(mk(i, { touches: ['./src/big.js'] }))
|
|
54
|
+
for (var j = 0; j < 4; j++) ts.push(mk(10 + j, { touches: ['src\\big.js'] }))
|
|
55
|
+
var hints = computeHealthHints(ts)
|
|
56
|
+
assert.equal(hints.length, 1)
|
|
57
|
+
assert.equal(hints[0].level, 'warn')
|
|
58
|
+
assert.ok(hints[0].text.indexOf('src/big.js 在近 8 张卡中被 8 次声明 touches(占比 100%)') >= 0)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// ===== 信号 b:串行代价代理 =====
|
|
62
|
+
test('信号 b 命中:有 touches 任务滞留中位数 > 无 touches 的 2 倍且样本 ≥5', function () {
|
|
63
|
+
var ts = []
|
|
64
|
+
for (var i = 0; i < 6; i++) ts.push(mk(i, { touches: ['src/x' + i + '.js'], claimedAt: iso(BASE + i * MIN + 30 * MIN), resolvedAt: iso(BASE + i * MIN + 31 * MIN) }))
|
|
65
|
+
for (var j = 0; j < 6; j++) ts.push(mk(100 + j, { claimedAt: iso(BASE + (100 + j) * MIN + 3 * MIN), resolvedAt: iso(BASE + (100 + j) * MIN + 4 * MIN) }))
|
|
66
|
+
var hints = computeHealthHints(ts)
|
|
67
|
+
assert.equal(hints.length, 1)
|
|
68
|
+
assert.equal(hints[0].level, 'info')
|
|
69
|
+
assert.ok(hints[0].text.indexOf('中位数 30min vs 3min') >= 0)
|
|
70
|
+
assert.ok(hints[0].text.indexOf('并行度受锁限制') >= 0)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('信号 b 不命中:有 touches 任务样本 <5', function () {
|
|
74
|
+
var ts = []
|
|
75
|
+
for (var i = 0; i < 4; i++) ts.push(mk(i, { touches: ['src/x' + i + '.js'], claimedAt: iso(BASE + i * MIN + 60 * MIN) }))
|
|
76
|
+
for (var j = 0; j < 4; j++) ts.push(mk(100 + j, { claimedAt: iso(BASE + (100 + j) * MIN + MIN) }))
|
|
77
|
+
assert.deepEqual(computeHealthHints(ts), [])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// ===== 信号 c:时长 p90 =====
|
|
81
|
+
test('信号 c 命中:resolved 任务时长 p90 > 45min', function () {
|
|
82
|
+
var ts = []
|
|
83
|
+
for (var i = 0; i < 8; i++) ts.push(mk(i, { claimedAt: iso(BASE + i * MIN + MIN), resolvedAt: iso(BASE + i * MIN + 10 * MIN) }))
|
|
84
|
+
for (var j = 0; j < 2; j++) ts.push(mk(100 + j, { claimedAt: iso(BASE + (100 + j) * MIN + MIN), resolvedAt: iso(BASE + (100 + j) * MIN + 120 * MIN) }))
|
|
85
|
+
// n=10,p90 = 升序第 ceil(0.9*10)=9 位 = 120min
|
|
86
|
+
var hints = computeHealthHints(ts)
|
|
87
|
+
assert.equal(hints.length, 1)
|
|
88
|
+
assert.equal(hints[0].level, 'info')
|
|
89
|
+
assert.ok(hints[0].text.indexOf('p90 已达 120min') >= 0)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('信号 c 不命中:p90 未超阈值', function () {
|
|
93
|
+
var ts = []
|
|
94
|
+
for (var i = 0; i < 10; i++) ts.push(mk(i, { claimedAt: iso(BASE + i * MIN + MIN), resolvedAt: iso(BASE + i * MIN + 30 * MIN) }))
|
|
95
|
+
assert.deepEqual(computeHealthHints(ts), [])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// ===== 信号 d:驳回热点 =====
|
|
99
|
+
test('信号 d 命中:同路径累计驳回 ≥2 次 → warn', function () {
|
|
100
|
+
var ts = [
|
|
101
|
+
mk(0, { touches: ['src/fragile.js'], rejectCount: 1 }),
|
|
102
|
+
mk(1, { touches: ['src/fragile.js'], rejectCount: 1 }),
|
|
103
|
+
]
|
|
104
|
+
var hints = computeHealthHints(ts)
|
|
105
|
+
assert.equal(hints.length, 1)
|
|
106
|
+
assert.equal(hints[0].level, 'warn')
|
|
107
|
+
assert.ok(hints[0].text.indexOf('src/fragile.js 相关任务被驳回 2 次') >= 0)
|
|
108
|
+
assert.ok(hints[0].text.indexOf('质量脆弱区') >= 0)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('信号 d 不命中:驳回 <2 次;无 touches 的驳回无法归因', function () {
|
|
112
|
+
assert.deepEqual(computeHealthHints([mk(0, { touches: ['src/a.js'], rejectCount: 1 })]), [])
|
|
113
|
+
assert.deepEqual(computeHealthHints([mk(0, { rejectCount: 5 })]), [])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// ===== 窗口边界:只统计近 50 张卡(含归档)=====
|
|
117
|
+
test('窗口边界:热点滑出近 50 张窗口后不再报警', function () {
|
|
118
|
+
var hot = []
|
|
119
|
+
for (var i = 0; i < 10; i++) hot.push(mk(i, { touches: ['src/old.js'], rejectCount: 1, status: 'archived' }))
|
|
120
|
+
// 对照组:热点卡在窗口内时确实命中(信号 a + d)
|
|
121
|
+
assert.ok(computeHealthHints(hot).length >= 2)
|
|
122
|
+
// 追加 50 张更新的普通卡,把热点挤出窗口
|
|
123
|
+
var all = hot.slice()
|
|
124
|
+
for (var j = 0; j < 50; j++) all.push(mk(100 + j, { claimedAt: iso(BASE + (100 + j) * MIN + MIN), resolvedAt: iso(BASE + (100 + j) * MIN + 2 * MIN) }))
|
|
125
|
+
assert.equal(all.length, 60)
|
|
126
|
+
assert.deepEqual(computeHealthHints(all), [])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// ===== 提示上限:最多 3 条,warn 优先 =====
|
|
130
|
+
test('四信号同时命中时截断为 3 条(warn 优先)', function () {
|
|
131
|
+
var ts = []
|
|
132
|
+
// 8 张热点卡:touches src/hot.js(信号 a:8/14=57%)、各驳回 1 次(信号 d:累计 8 次)、滞留 30min、时长 100min
|
|
133
|
+
for (var i = 0; i < 8; i++) ts.push(mk(i, { touches: ['src/hot.js'], rejectCount: 1, claimedAt: iso(BASE + i * MIN + 30 * MIN), resolvedAt: iso(BASE + i * MIN + 100 * MIN) }))
|
|
134
|
+
// 6 张其他 touches 卡:滞留 30min、时长 100min(凑信号 b 样本与信号 c)
|
|
135
|
+
for (var j = 0; j < 6; j++) ts.push(mk(100 + j, { touches: ['src/other.js'], claimedAt: iso(BASE + (100 + j) * MIN + 30 * MIN), resolvedAt: iso(BASE + (100 + j) * MIN + 100 * MIN) }))
|
|
136
|
+
// 6 张无 touches 卡:滞留 3min、时长 100min(信号 b 对照组)
|
|
137
|
+
for (var k = 0; k < 6; k++) ts.push(mk(200 + k, { claimedAt: iso(BASE + (200 + k) * MIN + 3 * MIN), resolvedAt: iso(BASE + (200 + k) * MIN + 100 * MIN) }))
|
|
138
|
+
var hints = computeHealthHints(ts)
|
|
139
|
+
assert.equal(hints.length, 3)
|
|
140
|
+
assert.deepEqual(hints.map(function (h) { return h.level }), ['warn', 'warn', 'info'])
|
|
141
|
+
assert.ok(hints[0].text.indexOf('src/hot.js') >= 0)
|
|
142
|
+
assert.ok(hints[1].text.indexOf('驳回 8 次') >= 0)
|
|
143
|
+
assert.ok(hints[2].text.indexOf('并行度受锁限制') >= 0)
|
|
144
|
+
})
|