dsh-workplan 1.5.1 → 1.5.2
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/CHANGELOG.md +145 -2
- package/client.js +152 -32
- package/lib/host.js +1 -1
- package/lib/plan.js +112 -8
- package/lib/runtime.js +83 -12
- package/package.json +1 -1
- package/src/client.ts +149 -32
- package/src/host.ts +1 -1
- package/src/plan.ts +110 -8
- package/src/runtime.ts +83 -11
package/src/client.ts
CHANGED
|
@@ -45,6 +45,9 @@ window.__ModuleLoader__.load({
|
|
|
45
45
|
"helpNobody": "还没开始",
|
|
46
46
|
"helpDone": "都做完了",
|
|
47
47
|
"helpNextDone": "没有下一步,这件事已完成",
|
|
48
|
+
"helpWaitActive": "等这一步做完,再继续下一项",
|
|
49
|
+
"helpNoPlan": "还没有安排任何工作",
|
|
50
|
+
"nowRemaining": "还剩 {n} 项",
|
|
48
51
|
"helpHint": "负责团队 = 谁来做;具体工作 = 要完成的一件事;卡住了 = 需要人处理。",
|
|
49
52
|
"handleBlocked": "先处理",
|
|
50
53
|
"resumeReady": "对主 Agent 说「续跑这个计划」,会从受阻处继续(已完成的不重跑)",
|
|
@@ -207,6 +210,9 @@ window.__ModuleLoader__.load({
|
|
|
207
210
|
"helpNobody": "Not started yet",
|
|
208
211
|
"helpDone": "Everything is done",
|
|
209
212
|
"helpNextDone": "Nothing next; this task is complete",
|
|
213
|
+
"helpWaitActive": "Wait for this step to finish, then continue",
|
|
214
|
+
"helpNoPlan": "No work has been scheduled yet",
|
|
215
|
+
"nowRemaining": "{n} item(s) left",
|
|
210
216
|
"helpHint": "Team = who does it; work item = one thing to finish; stuck = a person needs to help.",
|
|
211
217
|
"handleBlocked": "Fix first",
|
|
212
218
|
"resumeReady": "Tell the main agent to resume this plan; it continues from the blocked step without re-running finished work",
|
|
@@ -1028,14 +1034,21 @@ window.__ModuleLoader__.load({
|
|
|
1028
1034
|
}
|
|
1029
1035
|
|
|
1030
1036
|
function workerText(session: any, t: any) {
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1037
|
+
/*
|
|
1038
|
+
* [1.5.2] 与 `planNow` 同序:卡住优先。
|
|
1039
|
+
*
|
|
1040
|
+
* 旧顺序是「正在做」优先,而卡片右上角的「现在」是「卡住了」优先,
|
|
1041
|
+
* 于是同一屏一边说「现在卡住了 T1」、一边说「正在做 T2」(T5 C12)。
|
|
1042
|
+
* 卡住需要人介入,两个位置都应先报它。
|
|
1043
|
+
*/
|
|
1035
1044
|
const blocked = findEntry(session, 'blocked')
|
|
1036
1045
|
if (blocked) {
|
|
1037
1046
|
return `${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}(${t('status.blocked')})`
|
|
1038
1047
|
}
|
|
1048
|
+
const active = findEntry(session, 'in_progress')
|
|
1049
|
+
if (active) {
|
|
1050
|
+
return `${deptName(t, active.subtask.department)} · ${active.subtask.title}`
|
|
1051
|
+
}
|
|
1039
1052
|
const stats = session.stats || {}
|
|
1040
1053
|
if ((stats.total ?? 0) > 0 && (stats.done ?? 0) >= (stats.total ?? 0)) return t('helpDone')
|
|
1041
1054
|
return t('helpNobody')
|
|
@@ -1061,11 +1074,22 @@ window.__ModuleLoader__.load({
|
|
|
1061
1074
|
function nextText(session: any, t: any) {
|
|
1062
1075
|
const blocked = findEntry(session, 'blocked')
|
|
1063
1076
|
if (blocked) return `${t('handleBlocked')}:${deptName(t, blocked.subtask.department)} · ${blocked.subtask.title}${resumeHint(session, t)}`
|
|
1077
|
+
/*
|
|
1078
|
+
* [1.5.2] 「没有下一步」只允许出现在真正全部完成之后。
|
|
1079
|
+
*
|
|
1080
|
+
* 旧实现找不到 pending 就直接 return「没有下一步,这件事已完成」,于是
|
|
1081
|
+
* · 单任务计划刚进入 in_progress(环 0%)→ 同一张卡宣布「已完成」(T5 C2);
|
|
1082
|
+
* · 降级计划 0/0 → 一边「安排失败」一边「已完成」(T5 C11)。
|
|
1083
|
+
* 现在先按计数判终态,再区分「等当前这一步」「根本没有计划」。
|
|
1084
|
+
*/
|
|
1085
|
+
const counts = planCounts(session.stats || {})
|
|
1086
|
+
if (counts.total > 0 && counts.done === counts.total) return t('helpDone')
|
|
1087
|
+
if (counts.total === 0) return t('helpNoPlan')
|
|
1064
1088
|
const next = findEntry(session, 'pending') || allEntries(session).find((entry: any) => !entry.item)
|
|
1065
1089
|
if (next) return `${deptName(t, next.subtask.department)} · ${next.subtask.title}`
|
|
1066
|
-
const
|
|
1067
|
-
if (
|
|
1068
|
-
return t('
|
|
1090
|
+
const active = findEntry(session, 'in_progress')
|
|
1091
|
+
if (active) return `${t('helpWaitActive')}:${deptName(t, active.subtask.department)} · ${active.subtask.title}`
|
|
1092
|
+
return t('helpWaitActive')
|
|
1069
1093
|
}
|
|
1070
1094
|
|
|
1071
1095
|
function progressText(item: any, t: any) {
|
|
@@ -1339,15 +1363,19 @@ window.__ModuleLoader__.load({
|
|
|
1339
1363
|
const done = items.filter(({ item }: any) => item?.status === 'done').length
|
|
1340
1364
|
const blocked = items.filter(({ item }: any) => item?.status === 'blocked').length
|
|
1341
1365
|
const inProgress = items.some(({ item }: any) => item?.status === 'in_progress')
|
|
1342
|
-
|
|
1366
|
+
// [1.5.2] 与 `planStageRows` 同一条规则:有 done 就是 active,不再只在
|
|
1367
|
+
// 有 in_progress 时才算「正在做」。旧规则让同一阶段在概览行显示「正在做」、
|
|
1368
|
+
// 在阶段卡显示「还没开始」(T5 C9)。
|
|
1369
|
+
const tone = blocked > 0 ? 'blocked' : items.length > 0 && done === items.length ? 'done' : inProgress || done > 0 ? 'active' : 'idle'
|
|
1343
1370
|
// Same tone/counts as before; only the presentation moved into the
|
|
1344
1371
|
// collapsed summary row (done/total + state pill), so the numbers keep one
|
|
1345
1372
|
// source: this step's own items from `statuses`.
|
|
1346
1373
|
const stateText = blocked > 0
|
|
1347
1374
|
? `${blocked} ${t('stats.blocked')}`
|
|
1348
|
-
: done === items.length
|
|
1375
|
+
: items.length > 0 && done === items.length
|
|
1349
1376
|
? t('helpDone')
|
|
1350
|
-
: inProgress ? t('phase.executing')
|
|
1377
|
+
: inProgress ? t('phase.executing')
|
|
1378
|
+
: done > 0 ? fill(t('nowRemaining'), { n: items.length - done }) : t('phase.pending')
|
|
1351
1379
|
return h('section', { className: `dsh-wp-stage dsh-wp-tone-${tone}` },
|
|
1352
1380
|
h(Collapse, {
|
|
1353
1381
|
className: 'dsh-wp-stage-collapse',
|
|
@@ -1463,6 +1491,18 @@ window.__ModuleLoader__.load({
|
|
|
1463
1491
|
return Math.max(0, Math.round(number))
|
|
1464
1492
|
}
|
|
1465
1493
|
|
|
1494
|
+
/*
|
|
1495
|
+
* [1.5.2] 计划级百分比的唯一算式。
|
|
1496
|
+
*
|
|
1497
|
+
* 1.5.1 及以前,同一页面上至少有三处各自 `Math.round(done / total * 100)`:
|
|
1498
|
+
* 卡片环(planCounts)、组头环(groupTotals)、按任务进度条(TaskBars / 总览柱状图)。
|
|
1499
|
+
* 三份实现口径相同只是巧合,任何一处单独改动都会让「同一张卡两个百分比」重现。
|
|
1500
|
+
* 现在只有这一个函数,其它地方一律调用它。
|
|
1501
|
+
*/
|
|
1502
|
+
function planPercent(done: number, total: number): number | null {
|
|
1503
|
+
return total > 0 ? Math.round((done / total) * 100) : null
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1466
1506
|
// 与 groupTotals 同一套口径:先夹到 [0,total],再让「正在做/还没开始」
|
|
1467
1507
|
// 吃掉剩余量,四段之和恒等于总数(构成条不会溢出或出现负宽度)。
|
|
1468
1508
|
function planCounts(stats: any): PlanCounts {
|
|
@@ -1472,8 +1512,44 @@ window.__ModuleLoader__.load({
|
|
|
1472
1512
|
const blocked = Math.min(planCount(source.blocked), total - done)
|
|
1473
1513
|
const active = Math.min(planCount(source.inProgress), total - done - blocked)
|
|
1474
1514
|
const pending = Math.max(0, total - done - blocked - active)
|
|
1475
|
-
|
|
1476
|
-
|
|
1515
|
+
return { total, done, active, blocked, pending, percent: planPercent(done, total) }
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/*
|
|
1519
|
+
* [1.5.2] 完成语义的第二道保险:状态文案必须服从计数事实。
|
|
1520
|
+
*
|
|
1521
|
+
* 后端已保证「工作项全部 done ⇒ phase=done」(`reconcilePhase`)。这里再兜一层,
|
|
1522
|
+
* 让任何历史载荷、旧缓存或第三方构造的 payload 都不可能渲染出「100% + 正在
|
|
1523
|
+
* 收尾」:计数齐全且全部完成时一律按「已完成」呈现;反过来,phase 说 done
|
|
1524
|
+
* 但计数里还有没做完的项时,按真实计数降级,避免「已完成下面挂着没做完的活」。
|
|
1525
|
+
*
|
|
1526
|
+
* 计数未知(total=0,例如已取消或尚未拆解)时不动原始 phase——此时没有事实
|
|
1527
|
+
* 可以覆盖它,硬改会把「进行中/已完成」的分组依据一起抹掉。
|
|
1528
|
+
* 「integrating」只在计数证明全部完成时升级为 done,否则保留原语义(回放旧会话)。
|
|
1529
|
+
*/
|
|
1530
|
+
function planPhaseOf(rawPhase: any, counts: PlanCounts): string {
|
|
1531
|
+
const phase = String(rawPhase ?? 'pending')
|
|
1532
|
+
if (counts.total > 0) {
|
|
1533
|
+
if (counts.done === counts.total) return 'done'
|
|
1534
|
+
if (phase === 'done') {
|
|
1535
|
+
if (counts.blocked > 0) return 'blocked'
|
|
1536
|
+
if (counts.active > 0) return 'executing'
|
|
1537
|
+
return 'pending'
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
return phase
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/*
|
|
1544
|
+
* [1.5.2] 加权总进度的展示口径:未全部完成时封顶 99。
|
|
1545
|
+
*
|
|
1546
|
+
* 服务端 `deriveAggregates` 已做同样封顶;这里再算一次是为了让旧载荷/手工
|
|
1547
|
+
* fixture 也守同一条不变量——「总体进度 100%」绝不能与「还有 N 项没完成」同屏。
|
|
1548
|
+
*/
|
|
1549
|
+
function planWeightedPercent(stats: any, counts: PlanCounts): number {
|
|
1550
|
+
const weighted = clampPct(stats?.progress ?? 0)
|
|
1551
|
+
if (counts.total > 0 && counts.done === counts.total) return 100
|
|
1552
|
+
return Math.min(99, weighted)
|
|
1477
1553
|
}
|
|
1478
1554
|
|
|
1479
1555
|
function planStatusMap(statuses: any): Map<string, any> {
|
|
@@ -1555,6 +1631,16 @@ window.__ModuleLoader__.load({
|
|
|
1555
1631
|
return { state: 'active', text: withTitle(t('stats.inProgress'), active?.title) }
|
|
1556
1632
|
}
|
|
1557
1633
|
if (counts.total > 0 && counts.done === counts.total) return { state: 'done', text: t('helpDone') }
|
|
1634
|
+
/*
|
|
1635
|
+
* [1.5.2] 部分完成不再落到「还没开始」。
|
|
1636
|
+
*
|
|
1637
|
+
* `done>0` 但没有 blocked、也没有 in_progress 时(预算把剩余项重置成
|
|
1638
|
+
* pending,或剩余项还没轮到),旧代码直接返回 `stats.pending`「还没开始」,
|
|
1639
|
+
* 与同一张卡上的「1/2 做好了」和 50% 直接冲突(T5 C5)。
|
|
1640
|
+
*/
|
|
1641
|
+
if (counts.done > 0) {
|
|
1642
|
+
return { state: 'active', text: fill(t('nowRemaining'), { n: counts.total - counts.done }) }
|
|
1643
|
+
}
|
|
1558
1644
|
return { state: 'pending', text: t('stats.pending') }
|
|
1559
1645
|
}
|
|
1560
1646
|
|
|
@@ -1643,7 +1729,9 @@ window.__ModuleLoader__.load({
|
|
|
1643
1729
|
if (rows.length < 1 || rows.length > 12) return null
|
|
1644
1730
|
return h('div', { className: 'dsh-wp-stepper', 'aria-hidden': 'true', 'data-stages': String(rows.length) },
|
|
1645
1731
|
rows.map((row) => {
|
|
1646
|
-
|
|
1732
|
+
// [1.5.2] 只按 done 计。旧式把 blocked/active 也算「触达」,于是环 50%
|
|
1733
|
+
// 时阶段条已经全满(T5 C7)——同一张卡两个「完成度」。
|
|
1734
|
+
const reach = row.total > 0 ? (row.done / row.total) * 100 : 0
|
|
1647
1735
|
const width = row.tone === 'pending' ? 0 : reach
|
|
1648
1736
|
return h('span', {
|
|
1649
1737
|
key: row.id,
|
|
@@ -1664,11 +1752,20 @@ window.__ModuleLoader__.load({
|
|
|
1664
1752
|
}
|
|
1665
1753
|
|
|
1666
1754
|
function PlanViz({ t, counts, now, rows }: { t: (key: string) => string; counts: PlanCounts; now: PlanNow; rows: PlanStageRow[] }) {
|
|
1755
|
+
/*
|
|
1756
|
+
* [1.5.2] data-state 也是「完成语义」的一个读者。
|
|
1757
|
+
*
|
|
1758
|
+
* 旧实现只看 percent:0% 的 pending 卡拿到 data-state="active",
|
|
1759
|
+
* 而 100% 的卡即使 phase 说 integrating 也是 "done"(T5 C13/C1)。
|
|
1760
|
+
* 现在与胶囊、分组、环共用计数:全 done 才 done,0 且有未完成才 idle。
|
|
1761
|
+
*/
|
|
1667
1762
|
const state = counts.percent === null
|
|
1668
1763
|
? 'idle'
|
|
1669
1764
|
: counts.blocked > 0
|
|
1670
1765
|
? 'blocked'
|
|
1671
|
-
: counts.
|
|
1766
|
+
: counts.total > 0 && counts.done === counts.total
|
|
1767
|
+
? 'done'
|
|
1768
|
+
: counts.done > 0 || counts.active > 0 ? 'active' : 'idle'
|
|
1672
1769
|
return h('div', {
|
|
1673
1770
|
className: 'dsh-wp-planviz',
|
|
1674
1771
|
'data-percent': counts.percent === null ? 'none' : String(counts.percent),
|
|
@@ -1708,7 +1805,7 @@ window.__ModuleLoader__.load({
|
|
|
1708
1805
|
h('span', null, `${counts.done}/${counts.total} ${t('stats.done')}`),
|
|
1709
1806
|
),
|
|
1710
1807
|
h('div', { className: 'dsh-wp-planoverview-stats' },
|
|
1711
|
-
h('span', null, `${t('vizWeighted')} ${
|
|
1808
|
+
h('span', null, `${t('vizWeighted')} ${planWeightedPercent(stats, counts)}%`),
|
|
1712
1809
|
h('span', null, `${counts.active} ${t('stats.inProgress')} · ${counts.pending} ${t('stats.pending')}`),
|
|
1713
1810
|
counts.blocked > 0 ? h('span', null, `${counts.blocked} ${t('stats.blocked')}`) : null,
|
|
1714
1811
|
),
|
|
@@ -1742,13 +1839,18 @@ window.__ModuleLoader__.load({
|
|
|
1742
1839
|
|
|
1743
1840
|
function SessionCard({ session, t, open, onToggle }: any) {
|
|
1744
1841
|
const domId = `dsh-wp-session-${session.sessionId}`
|
|
1745
|
-
const phase = session.phase || 'pending'
|
|
1746
1842
|
const stats = session.stats || {}
|
|
1747
|
-
const tone = phaseTone(phase)
|
|
1748
|
-
const departments = Array.isArray(session.departments) ? session.departments : []
|
|
1749
1843
|
// 1.5.0:折叠卡摘要=图表(环 + 百分比 + 四段构成条 + 图例 + 阶段步进条);
|
|
1750
1844
|
// 数字仍只来自 session.stats,与旧头部的口径完全一致,不引入第二套统计。
|
|
1751
1845
|
const counts = planCounts(stats)
|
|
1846
|
+
/*
|
|
1847
|
+
* [1.5.2] 卡片的色调与胶囊文案也走统一对账:只要计数显示全部完成,
|
|
1848
|
+
* 胶囊就必须是「已完成」,视觉档位也必须是 done。截图里那张卡之所以
|
|
1849
|
+
* 「100% + 正在收尾 + 留在进行中组」,就是因为这三个读者各读各的字段。
|
|
1850
|
+
*/
|
|
1851
|
+
const phase = planPhaseOf(session.phase || 'pending', counts)
|
|
1852
|
+
const tone = phaseTone(phase)
|
|
1853
|
+
const departments = Array.isArray(session.departments) ? session.departments : []
|
|
1752
1854
|
const statuses = Array.isArray(session.statuses) ? session.statuses : []
|
|
1753
1855
|
const stageRows = planStageRows(session.stages, statuses)
|
|
1754
1856
|
const now = planNow(t, counts, planTaskEntries(session.stages, statuses))
|
|
@@ -1989,7 +2091,7 @@ window.__ModuleLoader__.load({
|
|
|
1989
2091
|
total,
|
|
1990
2092
|
done,
|
|
1991
2093
|
open: total - done,
|
|
1992
|
-
percent:
|
|
2094
|
+
percent: planPercent(done, total),
|
|
1993
2095
|
active: Math.max(0, active),
|
|
1994
2096
|
pending: Math.max(0, pending),
|
|
1995
2097
|
blocked: Math.max(0, blocked),
|
|
@@ -2209,10 +2311,11 @@ window.__ModuleLoader__.load({
|
|
|
2209
2311
|
h('div', { className: 'dsh-wp-section-title' }, t('taskChart')),
|
|
2210
2312
|
(Array.isArray(sessions) ? sessions : []).map((session: any) => {
|
|
2211
2313
|
const stats = session.stats || {}
|
|
2212
|
-
const
|
|
2213
|
-
|
|
2214
|
-
const percent =
|
|
2215
|
-
const
|
|
2314
|
+
const counts = planCounts(stats)
|
|
2315
|
+
// 同一套计数与对账:柱状图的百分比、文案、色调都不得自成一套。
|
|
2316
|
+
const percent = counts.percent ?? 0
|
|
2317
|
+
const phase = planPhaseOf(session.phase || 'pending', counts)
|
|
2318
|
+
const tone = phaseTone(phase)
|
|
2216
2319
|
return h('button', {
|
|
2217
2320
|
key: session.sessionId,
|
|
2218
2321
|
type: 'button',
|
|
@@ -2222,13 +2325,13 @@ window.__ModuleLoader__.load({
|
|
|
2222
2325
|
},
|
|
2223
2326
|
h('div', { className: 'dsh-wp-bar-head' },
|
|
2224
2327
|
h('span', { className: 'dsh-wp-bar-title' }, session.title || t('panel')),
|
|
2225
|
-
h('span', { className: 'dsh-wp-bar-value' }, `${done}/${total}`),
|
|
2328
|
+
h('span', { className: 'dsh-wp-bar-value' }, `${counts.done}/${counts.total}`),
|
|
2226
2329
|
),
|
|
2227
2330
|
h('div', { className: 'dsh-wp-bar-track' },
|
|
2228
2331
|
h('div', { className: `dsh-wp-bar-fill dsh-wp-bar-fill--${tone}`, style: { width: `${percent}%` } }),
|
|
2229
2332
|
),
|
|
2230
2333
|
h('div', { className: 'dsh-wp-bar-meta' },
|
|
2231
|
-
h('span', null, t(`phase.${
|
|
2334
|
+
h('span', null, t(`phase.${phase}`)),
|
|
2232
2335
|
h('span', null, `${percent}%`),
|
|
2233
2336
|
),
|
|
2234
2337
|
)
|
|
@@ -2282,8 +2385,16 @@ window.__ModuleLoader__.load({
|
|
|
2282
2385
|
const live: any[] = []
|
|
2283
2386
|
const finished: any[] = []
|
|
2284
2387
|
const urgentPhases = ['blocked', 'degraded', 'paused']
|
|
2388
|
+
/*
|
|
2389
|
+
* [1.5.2] 分组也按对账后的 phase,而不是 payload 里的原始 phase。
|
|
2390
|
+
*
|
|
2391
|
+
* 截图那张卡之所以停在「进行中」组,就是因为这里只读 `session.phase`:
|
|
2392
|
+
* 14/14、100% 的计划带着 integrating 被塞进 live 组,组头于是出现
|
|
2393
|
+
* 「未完成 0 / 已完成 14(完成率 100%)」却仍标着「进行中」。
|
|
2394
|
+
* `planPhaseOf` 让分组、组头计数、卡片胶囊共用同一个结论。
|
|
2395
|
+
*/
|
|
2285
2396
|
for (const session of activeSessions) {
|
|
2286
|
-
if (session.phase === 'done') finished.push(session)
|
|
2397
|
+
if (planPhaseOf(session.phase, planCounts(session.stats)) === 'done') finished.push(session)
|
|
2287
2398
|
else live.push(session)
|
|
2288
2399
|
}
|
|
2289
2400
|
/*
|
|
@@ -2301,7 +2412,7 @@ window.__ModuleLoader__.load({
|
|
|
2301
2412
|
planning: 4,
|
|
2302
2413
|
pending: 5,
|
|
2303
2414
|
}
|
|
2304
|
-
const rank = (session: any) => phaseRank[session.phase] ?? 5
|
|
2415
|
+
const rank = (session: any) => phaseRank[planPhaseOf(session.phase, planCounts(session.stats))] ?? 5
|
|
2305
2416
|
live.sort((a: any, b: any) => (rank(a) - rank(b))
|
|
2306
2417
|
|| ((Number(b.createdAt) || 0) - (Number(a.createdAt) || 0))
|
|
2307
2418
|
|| String(a.sessionId).localeCompare(String(b.sessionId)))
|
|
@@ -2330,12 +2441,11 @@ window.__ModuleLoader__.load({
|
|
|
2330
2441
|
return { live, finished, urgent: live.filter((session: any) => urgentPhases.includes(session.phase)).length }
|
|
2331
2442
|
}, [activeSessions])
|
|
2332
2443
|
const liveBars = useMemo(() => groups.live.slice(0, 6).map((session: any) => {
|
|
2333
|
-
const doneCount = session.stats?.done ?? 0
|
|
2334
|
-
const totalCount = session.stats?.total ?? 0
|
|
2335
2444
|
const raw = String(session.title || t('panel'))
|
|
2336
2445
|
return {
|
|
2337
2446
|
label: raw.length > 14 ? `${raw.slice(0, 14)}…` : raw,
|
|
2338
|
-
|
|
2447
|
+
// [1.5.2] 与卡片/组头同一个百分比函数,杜绝第二套口径。
|
|
2448
|
+
value: planPercent(planCount(session.stats?.done), planCount(session.stats?.total)) ?? 0,
|
|
2339
2449
|
}
|
|
2340
2450
|
}), [groups, t])
|
|
2341
2451
|
return h('div', { className: 'dsh-wp' },
|
|
@@ -2389,12 +2499,19 @@ window.__ModuleLoader__.load({
|
|
|
2389
2499
|
const active = ['planning', 'executing', 'blocked'].includes(session.phase)
|
|
2390
2500
|
if (currentTurn > createdTurn && !active) return null
|
|
2391
2501
|
}
|
|
2502
|
+
/*
|
|
2503
|
+
* [1.5.2] 会话内联卡(TurnCard)也走同一套对账。
|
|
2504
|
+
*
|
|
2505
|
+
* 面板卡片修好了、内联卡却仍按原始 phase 渲染的话,同一张计划会在
|
|
2506
|
+
* 「侧栏已完成 / 对话里正在收尾」之间再次自相矛盾。
|
|
2507
|
+
*/
|
|
2392
2508
|
const stats = session.stats || {}
|
|
2393
|
-
const
|
|
2509
|
+
const phase = planPhaseOf(session.phase || 'pending', planCounts(stats))
|
|
2510
|
+
const tone = phaseTone(phase)
|
|
2394
2511
|
return h('div', { className: `dsh-wp-turn dsh-wp-tone-${tone}` },
|
|
2395
2512
|
h('div', { className: 'dsh-wp-turn-title' },
|
|
2396
2513
|
h('span', null, session.title || t('panel')),
|
|
2397
|
-
h(Pill, { tone }, t(`phase.${
|
|
2514
|
+
h(Pill, { tone }, t(`phase.${phase}`)),
|
|
2398
2515
|
),
|
|
2399
2516
|
h('div', { className: 'dsh-wp-muted', style: { marginTop: '6px' } },
|
|
2400
2517
|
`${t('helpWho')}:${workerText(session, t)}`),
|
package/src/host.ts
CHANGED
|
@@ -325,7 +325,7 @@ export function apply(ctx: any, config: WorkplanConfig = {}) {
|
|
|
325
325
|
|
|
326
326
|
export const meta = {
|
|
327
327
|
name,
|
|
328
|
-
version: '1.5.
|
|
328
|
+
version: '1.5.2',
|
|
329
329
|
promptOrder: PROMPT_ORDER,
|
|
330
330
|
routes: ['/dsh-workplan/plans', '/dsh-workplan/vendor/chart.umd.js'],
|
|
331
331
|
}
|
package/src/plan.ts
CHANGED
|
@@ -1074,8 +1074,16 @@ export function applyStatus(statuses: any, subtaskId: any, patch: any) {
|
|
|
1074
1074
|
if (patch.summary === undefined) next.summary = ''
|
|
1075
1075
|
if (patch.error === undefined) next.error = null
|
|
1076
1076
|
}
|
|
1077
|
-
if (patch.status === 'in_progress'
|
|
1078
|
-
next.startedAt = Date.now()
|
|
1077
|
+
if (patch.status === 'in_progress') {
|
|
1078
|
+
if (next.startedAt === null) next.startedAt = Date.now()
|
|
1079
|
+
/*
|
|
1080
|
+
* [1.5.2] 「正在做」不允许自称 100%。
|
|
1081
|
+
*
|
|
1082
|
+
* 工具允许 `{status:'in_progress', progress:100}`,界面于是出现
|
|
1083
|
+
* 「正在做 + 进度 100%」,计划层再叠一个环 0% / 加权 100%(T5 C8)。
|
|
1084
|
+
* 完成只能由 status='done' 表达,progress 到 99 就是这一步的上限。
|
|
1085
|
+
*/
|
|
1086
|
+
if (Number(next.progress) >= 100) next.progress = 99
|
|
1079
1087
|
}
|
|
1080
1088
|
if (patch.status === 'blocked' && next.finishedAt === null) {
|
|
1081
1089
|
next.finishedAt = Date.now()
|
|
@@ -1272,6 +1280,16 @@ export function deriveAggregates(statuses: readonly StatusEntry[]): {
|
|
|
1272
1280
|
}
|
|
1273
1281
|
|
|
1274
1282
|
const total = statuses.length
|
|
1283
|
+
const allDone = total > 0 && done === total
|
|
1284
|
+
/*
|
|
1285
|
+
* [1.5.2] 加权进度在「未全部完成」时不得四舍五入成 100。
|
|
1286
|
+
*
|
|
1287
|
+
* 旧算法直接 `round(progressSum / total)`:11 个 done + 1 个 blocked@95 得到
|
|
1288
|
+
* 100,卡片上就出现「总体进度 100%」旁边还有「1 卡住了 / 11/12 做好了」——
|
|
1289
|
+
* 与截图同类的自相矛盾(T4 实测 A.5)。这里的封顶只作用于展示口径,不影响
|
|
1290
|
+
* 任何计数;全部完成时仍严格为 100,从而与 `done === total` 一一对应。
|
|
1291
|
+
*/
|
|
1292
|
+
const raw = total === 0 ? 0 : Math.round(progressSum / total)
|
|
1275
1293
|
return {
|
|
1276
1294
|
stats: {
|
|
1277
1295
|
total,
|
|
@@ -1279,7 +1297,7 @@ export function deriveAggregates(statuses: readonly StatusEntry[]): {
|
|
|
1279
1297
|
blocked,
|
|
1280
1298
|
inProgress,
|
|
1281
1299
|
pending,
|
|
1282
|
-
progress:
|
|
1300
|
+
progress: allDone ? 100 : Math.min(99, raw),
|
|
1283
1301
|
},
|
|
1284
1302
|
departments: [...departments.values()],
|
|
1285
1303
|
}
|
|
@@ -1309,13 +1327,18 @@ export function computeStats(statuses: any): Stats {
|
|
|
1309
1327
|
}
|
|
1310
1328
|
|
|
1311
1329
|
/**
|
|
1312
|
-
* Invariant checker for tests and CI
|
|
1313
|
-
* pure derivation is already self-consistent.
|
|
1330
|
+
* Invariant checker for tests and CI.
|
|
1314
1331
|
*
|
|
1315
1332
|
* Returns an empty array when every invariant holds. Each message names the
|
|
1316
1333
|
* field and the observed value so it can be pasted straight into a report.
|
|
1334
|
+
*
|
|
1335
|
+
* [1.5.2] `phase` 是新增的可选入参:1.4.3 的 I1–I7 只校验计数内部自洽,
|
|
1336
|
+
* 完全不看 `phase` 与 `stats` 的关系,因此「14/14 且 progress=100 却写着
|
|
1337
|
+
* 正在收尾」这种**跨字段**矛盾一路畅通到界面。I8/I9 专门封住这一类:
|
|
1338
|
+
* 只要调用方把本次真正要下发的 phase 传进来,任何「进度与状态文案打架」
|
|
1339
|
+
* 的载荷都会在这里被判为违规,而不是等到用户看截图。
|
|
1317
1340
|
*/
|
|
1318
|
-
export function aggregateInvariantViolations(snapshot: PlanSnapshot): string[] {
|
|
1341
|
+
export function aggregateInvariantViolations(snapshot: PlanSnapshot, phase?: any): string[] {
|
|
1319
1342
|
const violations: string[] = []
|
|
1320
1343
|
const { statuses, stats, departments } = snapshot
|
|
1321
1344
|
|
|
@@ -1371,17 +1394,96 @@ export function aggregateInvariantViolations(snapshot: PlanSnapshot): string[] {
|
|
|
1371
1394
|
violations.push(`I7 各部门 inProgress 之和=${sums.inProgress} 与 stats.inProgress=${stats.inProgress} 不一致`)
|
|
1372
1395
|
}
|
|
1373
1396
|
|
|
1397
|
+
/*
|
|
1398
|
+
* [1.5.2] I8/I9 —— 完成语义跨字段不变量(用户截图那条「100% + 正在收尾」)。
|
|
1399
|
+
*
|
|
1400
|
+
* I8:工作项全部 done ⇔ phase 必须是 done。这是一条双向断言:
|
|
1401
|
+
* - 全 done 却是 pending/planning/executing/integrating/paused/blocked/degraded
|
|
1402
|
+
* ⇒ 卡片会同时出现 100% 与进行中文案(本次事故);
|
|
1403
|
+
* - phase=done 却还有未完成项 ⇒ 反向矛盾(「已完成」下面挂着没做完的活)。
|
|
1404
|
+
* I9:加权进度与计数同口径:全 done ⇒ 100;未全 done ⇒ 不得为 100。
|
|
1405
|
+
* 旧算法 round(progressSum/total) 让 11 done + 1 blocked@95 算出 100。
|
|
1406
|
+
*/
|
|
1407
|
+
if (stats.total > 0) {
|
|
1408
|
+
const allDone = stats.done === stats.total
|
|
1409
|
+
if (allDone && phase !== undefined && phase !== null && phase !== 'done') {
|
|
1410
|
+
violations.push(`I8 stats.done=${stats.done}/${stats.total} 已全部完成,但 phase=${String(phase)} 仍表示未完成`)
|
|
1411
|
+
}
|
|
1412
|
+
if (!allDone && phase === 'done') {
|
|
1413
|
+
violations.push(`I8 phase=done 但 stats.done=${stats.done}/${stats.total} 尚未全部完成`)
|
|
1414
|
+
}
|
|
1415
|
+
if (allDone && stats.progress !== 100) {
|
|
1416
|
+
violations.push(`I9 全部完成但 stats.progress=${stats.progress}`)
|
|
1417
|
+
}
|
|
1418
|
+
if (!allDone && stats.progress >= 100) {
|
|
1419
|
+
violations.push(`I9 未全部完成(${stats.done}/${stats.total})却显示 stats.progress=${stats.progress}`)
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
/*
|
|
1423
|
+
* I10:单个工作项也不能自相矛盾——`in_progress` 意味着「还没做完」,
|
|
1424
|
+
* 它的进度必须 < 100(旧载荷里出现过「正在做 + 100%」,T5 C8)。
|
|
1425
|
+
*/
|
|
1426
|
+
for (const item of statuses) {
|
|
1427
|
+
if (item?.status === 'in_progress' && Number(item?.progress) >= 100) {
|
|
1428
|
+
violations.push(`I10 子任务 ${String(item?.subtaskId)} 状态为 in_progress 但 progress=${item?.progress}`)
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1374
1432
|
return violations
|
|
1375
1433
|
}
|
|
1376
1434
|
|
|
1377
|
-
|
|
1435
|
+
/**
|
|
1436
|
+
* 计划级阶段,由状态列表唯一决定。
|
|
1437
|
+
*
|
|
1438
|
+
* [1.5.2] 修复「一边 100%、一边正在收尾」的根因。
|
|
1439
|
+
*
|
|
1440
|
+
* 旧实现的最后一步是 `return resultSeen ? 'done' : 'integrating'`:工作项全部
|
|
1441
|
+
* done 时仍要等「结果消息落盘」才晋升终态。问题在于 `resultSeen` 只是**持久化
|
|
1442
|
+
* 回放**才知道的副作用,内存路径(`updateStatus` / `serialisePlan`)拿不到它:
|
|
1443
|
+
*
|
|
1444
|
+
* - 主 Agent 用 `dsh_workplan_update` 补完最后几项时,phase 永远停在 integrating;
|
|
1445
|
+
* - 该路径不会再派发,也就永远不会写结果消息,于是 integrating 永久留存;
|
|
1446
|
+
* - 同期 `stats.done/total` 已是 14/14、百分比 100%,卡片就同时显示
|
|
1447
|
+
* 「100% · 都做完了」和「正在收尾」,并留在「进行中」组(用户截图)。
|
|
1448
|
+
*
|
|
1449
|
+
* 结论:完成语义只能有一个事实源——状态列表。工作项全部完成即计划完成,
|
|
1450
|
+
* 不需要也不允许等另一个副作用来「确认」。`resultSeen` 参数仅为兼容旧调用点保留,
|
|
1451
|
+
* 不再参与判定(旧的 integrating 窗口已由 dispatch 内的 `executing` 覆盖)。
|
|
1452
|
+
*/
|
|
1453
|
+
export function planPhase(statuses: any, _resultSeen = false) {
|
|
1378
1454
|
if (statuses.length === 0) return 'pending'
|
|
1379
|
-
if (statuses.every((item: any) => item.status === 'done')) return
|
|
1455
|
+
if (statuses.every((item: any) => item.status === 'done')) return 'done'
|
|
1380
1456
|
if (statuses.some((item: any) => item.status === 'blocked')) return 'blocked'
|
|
1381
1457
|
if (statuses.some((item: any) => item.status === 'in_progress' || item.status === 'done')) return 'executing'
|
|
1382
1458
|
return 'pending'
|
|
1383
1459
|
}
|
|
1384
1460
|
|
|
1461
|
+
/**
|
|
1462
|
+
* 把「缓存的 phase」与「由状态列表派生的 phase」对账成唯一结果。
|
|
1463
|
+
*
|
|
1464
|
+
* phase 是缓存(dispatch/预算/取消会写它),statuses 是事实源。只要两者可能
|
|
1465
|
+
* 分叉,就必须在出口处对账,否则同一个会话会因读取路径不同而翻转(实时
|
|
1466
|
+
* integrating / 刷新后 done,T2 场景 B)。
|
|
1467
|
+
*
|
|
1468
|
+
* 规则只有四条,全部可由 `aggregateInvariantViolations` 断言:
|
|
1469
|
+
* 1. 工作项全部 done ⇒ 一定是 done(缓存不得把它拖回任何进行中状态);
|
|
1470
|
+
* 2. 缓存是 done/integrating 但工作项没全 done ⇒ 缓存过期,回退到派生值;
|
|
1471
|
+
* 3. pending/planning/executing 是「状态列表能直接回答」的轮次状态:一旦
|
|
1472
|
+
* 有工作项开始(派生值不再是 pending),缓存里的旧 pending/planning 必须
|
|
1473
|
+
* 让位;而派生值仍是 pending 时保留缓存(dispatch 刚开始、状态尚未落盘)。
|
|
1474
|
+
* 4. 其余情况保留缓存的轮次状态(paused/cancelled/blocked/degraded)——
|
|
1475
|
+
* 它们携带状态列表里没有的信息(预算暂停、用户取消)。
|
|
1476
|
+
*/
|
|
1477
|
+
export function reconcilePhase(phase: any, statuses: any) {
|
|
1478
|
+
const derived = planPhase(statuses)
|
|
1479
|
+
if (derived === 'done') return 'done'
|
|
1480
|
+
if (phase === 'done' || phase === 'integrating') return derived
|
|
1481
|
+
if (phase === 'pending' || phase === 'planning' || phase === 'executing') {
|
|
1482
|
+
return derived === 'pending' ? phase : derived
|
|
1483
|
+
}
|
|
1484
|
+
return phase ?? derived
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1385
1487
|
export function nextReadyInStage(plan: any, statuses: any[], stageId: string) {
|
|
1386
1488
|
const byId = new Map(statuses.map((item: any) => [item.subtaskId, item]))
|
|
1387
1489
|
const stage = plan.stages.find((item: any) => item.id === stageId)
|