dsh-workplan 1.5.1 → 1.6.0
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 +267 -2
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/client.js +256 -54
- package/lib/host.js +1 -1
- package/lib/plan.js +112 -8
- package/lib/runtime.js +207 -23
- package/package.json +1 -1
- package/src/client.ts +253 -57
- package/src/host.ts +1 -1
- package/src/plan.ts +110 -8
- package/src/runtime.ts +201 -21
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.
|
|
328
|
+
version: '1.6.0',
|
|
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)
|
package/src/runtime.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
17
17
|
import {
|
|
18
18
|
MAX_CONCURRENT,
|
|
19
19
|
PlanError,
|
|
20
|
+
aggregateInvariantViolations,
|
|
20
21
|
applyStatus,
|
|
21
22
|
attachPlanMeta,
|
|
22
23
|
buildChildPrompt,
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
parseResultMessage,
|
|
44
45
|
parseWorkplanMessage,
|
|
45
46
|
planPhase,
|
|
47
|
+
reconcilePhase,
|
|
46
48
|
truncate,
|
|
47
49
|
validatePlan,
|
|
48
50
|
applyResultStatuses,
|
|
@@ -800,21 +802,30 @@ export async function dispatchPlan(ctx: any, record: any, options: any) {
|
|
|
800
802
|
})
|
|
801
803
|
}
|
|
802
804
|
record.statuses = reset
|
|
803
|
-
record.phase = 'paused'
|
|
804
805
|
const total = record.statuses.length
|
|
805
806
|
const done = record.statuses.filter((item: any) => item.status === 'done').length
|
|
807
|
+
/*
|
|
808
|
+
* [1.5.2] 预算到点 ≠ 一定是「暂停」。
|
|
809
|
+
*
|
|
810
|
+
* 旧代码在这里无条件 `record.phase = 'paused'`:如果最后一个子任务恰好在
|
|
811
|
+
* 截止时刻之后完成(done === total),计划会以 14/14、progress=100 的载荷
|
|
812
|
+
* 带着 paused 返回,界面就是「100% + 预算用完,可续跑」(T5 C4)。
|
|
813
|
+
* 全部完成时直接进终态,并且不再写「其余 0 项保留为未完成」这种自相矛盾的说明。
|
|
814
|
+
*/
|
|
815
|
+
const allDoneNow = total > 0 && done === total
|
|
816
|
+
record.phase = allDoneNow ? 'done' : 'paused'
|
|
806
817
|
const minutes = Math.max(1, Math.round((options.totalTimeoutMs ?? TOTAL_TIMEOUT_MS) / 60000))
|
|
807
|
-
record.error =
|
|
818
|
+
record.error = allDoneNow
|
|
819
|
+
? null
|
|
820
|
+
: `本轮执行预算(${minutes} 分钟)已到:已完成 ${done}/${total},其余 ${total - done} 项保留为未完成,再次派发本计划即可续跑(已完成的不重跑)`
|
|
808
821
|
/*
|
|
809
822
|
* Drop the failure lines the aborted children wrote on their way out.
|
|
810
823
|
* They used to survive into the result payload as
|
|
811
824
|
* `["T4: 总执行超时", "总执行超时", …]`, so the model read a failure
|
|
812
825
|
* report for subtasks that were explicitly *not* failures.
|
|
813
826
|
*/
|
|
814
|
-
record.errors = [
|
|
815
|
-
|
|
816
|
-
record.error,
|
|
817
|
-
].slice(-20)
|
|
827
|
+
record.errors = (record.errors ?? []).filter((line: string) => !/总执行超时/.test(String(line)))
|
|
828
|
+
if (!allDoneNow) record.errors = [...record.errors, record.error].slice(-20)
|
|
818
829
|
} else {
|
|
819
830
|
/*
|
|
820
831
|
* `blocked` is a terminal state, so it always reports progress 100 — the
|
|
@@ -845,6 +856,58 @@ export async function dispatchPlan(ctx: any, record: any, options: any) {
|
|
|
845
856
|
return record
|
|
846
857
|
}
|
|
847
858
|
|
|
859
|
+
/*
|
|
860
|
+
* [1.6.0] 会话「这一轮到底还在不在跑」的唯一事实源。
|
|
861
|
+
*
|
|
862
|
+
* 事故(session-7129faea…):会话早已结束,面板仍把计划列在「进行中」组,
|
|
863
|
+
* 卡片写着「预算用完,可续跑」——没有任何东西在跑,状态却表示正在跑。
|
|
864
|
+
*
|
|
865
|
+
* 根因:插件只订阅 `agent/pre-step`,`turn/end`、`session/disposed` 全都收不到;
|
|
866
|
+
* 计划状态因此只由「派发结果」驱动,而派发结束后不再有任何事件把它收敛。
|
|
867
|
+
*
|
|
868
|
+
* 修复不引入第二个事件订阅(跨 scope 的事件投递受 scope 过滤,不可靠;而且
|
|
869
|
+
* 订阅只覆盖实时路径,刷新/重启后全靠回放,两条链路一旦不同源就会来回翻转)。
|
|
870
|
+
* 这里直接读会话日志本身——它既是实时事实,也是回放事实:
|
|
871
|
+
*
|
|
872
|
+
* - 从末尾向前找最近的一条「轮次边界」事件:先遇到 `turn/end` ⇒ 这一轮已结束;
|
|
873
|
+
* 先遇到 `turn/start` ⇒ 这一轮还开着;都没找到 ⇒ 未知,调用方保持原判定。
|
|
874
|
+
* - 不存在「会话已结束」这种事件:宿主只有每轮 `turn/end`。因此**不能**把
|
|
875
|
+
* `turn/end` 直接等同于「计划取消/完成」——一轮结束后可以随时续跑,这也是
|
|
876
|
+
* 卡片提供「可续跑」的原因。它只证明「此刻没有东西在跑」。
|
|
877
|
+
* - 向后扫描设上限,避免越界;超过上限就返回 unknown,绝不猜测。
|
|
878
|
+
*/
|
|
879
|
+
const TURN_BOUNDARY_SCAN = 200
|
|
880
|
+
|
|
881
|
+
export function sessionTurnState(session: any) {
|
|
882
|
+
const total = Number(session?.seq)
|
|
883
|
+
if (!Number.isFinite(total) || total <= 0 || typeof session?.eventAt !== 'function') {
|
|
884
|
+
return { state: 'unknown', reason: null, reasonDetail: null, at: null }
|
|
885
|
+
}
|
|
886
|
+
const floor = Math.max(0, Math.floor(total) - TURN_BOUNDARY_SCAN)
|
|
887
|
+
for (let seq = Math.floor(total) - 1; seq >= floor; seq -= 1) {
|
|
888
|
+
let event
|
|
889
|
+
try {
|
|
890
|
+
event = session.eventAt(seq)
|
|
891
|
+
} catch {
|
|
892
|
+
return { state: 'unknown', reason: null, reasonDetail: null, at: null }
|
|
893
|
+
}
|
|
894
|
+
if (!event) continue
|
|
895
|
+
if (event.type === 'turn/start') return { state: 'open', reason: null, reasonDetail: null, at: null }
|
|
896
|
+
if (event.type === 'turn/end') {
|
|
897
|
+
const reason = event.data?.reason ?? null
|
|
898
|
+
const detail = reason && reason.kind === 'aborted' ? (reason.reason?.kind ?? null) : null
|
|
899
|
+
const at = Number(event.time)
|
|
900
|
+
return {
|
|
901
|
+
state: 'ended',
|
|
902
|
+
reason: typeof reason?.kind === 'string' ? reason.kind : 'completed',
|
|
903
|
+
reasonDetail: typeof detail === 'string' ? detail : null,
|
|
904
|
+
at: Number.isFinite(at) ? at : null,
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
return { state: 'unknown', reason: null, reasonDetail: null, at: null }
|
|
909
|
+
}
|
|
910
|
+
|
|
848
911
|
function serialisePlan(record: any, session: any) {
|
|
849
912
|
const plan = record.plan ?? null
|
|
850
913
|
/*
|
|
@@ -865,15 +928,61 @@ function serialisePlan(record: any, session: any) {
|
|
|
865
928
|
)
|
|
866
929
|
const statuses = snapshot.statuses
|
|
867
930
|
const stats = snapshot.stats
|
|
868
|
-
|
|
931
|
+
/*
|
|
932
|
+
* [1.5.2] 出口对账,而不是无条件相信内存缓存。
|
|
933
|
+
*
|
|
934
|
+
* 旧写法 `record.phase ?? planPhase(statuses)` 让缓存一旦存在就完全绕过
|
|
935
|
+
* statuses:一次 `updateStatus` 把计划写成 integrating 之后,即使 14/14 全部
|
|
936
|
+
* 完成,下发的 phase 仍是 integrating,界面于是「100% + 正在收尾」。这里改成
|
|
937
|
+
* `reconcilePhase`:全部完成一律 done,缓存过期的 done/integrating 回退到派生值。
|
|
938
|
+
*/
|
|
939
|
+
let phase = reconcilePhase(record.phase ?? (plan ? planPhase(statuses) : 'degraded'), statuses)
|
|
869
940
|
if (phase === 'pending' && !record.dispatchedAt && plan?.createdAt && Date.now() - plan.createdAt > STALE_PENDING_MS) {
|
|
870
941
|
phase = 'cancelled'
|
|
871
942
|
}
|
|
943
|
+
/*
|
|
944
|
+
* [1.6.0] 终态收敛:这一轮已经结束,而计划没有完成 ⇒ 不许再显示「进行中」。
|
|
945
|
+
*
|
|
946
|
+
* `phase` 到这里为止只由「派发动作」写入(executing / paused / blocked …),
|
|
947
|
+
* 而派发结束后没有任何事件把它改回来,于是会话结束、进程空闲、面板却仍在
|
|
948
|
+
* 「进行中」组里(事故截图)。上面 `sessionTurnState` 用会话日志回答
|
|
949
|
+
* 「此刻还有没有东西在跑」,这里把答案落成独立阶段 `ended`:
|
|
950
|
+
*
|
|
951
|
+
* - done / cancelled 已经是终态,不再改写(done 改写会直接违反不变量 I8);
|
|
952
|
+
* - 其余阶段一律收敛为 `ended`,并把原阶段留在 `suspendedPhase` 里,
|
|
953
|
+
* 卡片因此仍能显示「预算用完,可续跑」「卡住了」这类二级事实,不丢信息;
|
|
954
|
+
* - 只改**下发出去的那一份**,不动 `record.phase`:用户说「继续」后同一轮
|
|
955
|
+
* 重新开始(日志里出现新的 turn/start),下一份 payload 自然回到原来的
|
|
956
|
+
* 派发状态,不需要任何反向迁移逻辑;
|
|
957
|
+
* - 实时路径与回放路径都经过本函数,因此刷新前后结论必然一致(T4 硬约束)。
|
|
958
|
+
*/
|
|
959
|
+
let suspendedPhase: any = null
|
|
960
|
+
let endReason: any = null
|
|
961
|
+
let endReasonDetail: any = null
|
|
962
|
+
let endedAt: any = null
|
|
963
|
+
if (phase !== 'done' && phase !== 'cancelled') {
|
|
964
|
+
const turn = sessionTurnState(session)
|
|
965
|
+
if (turn.state === 'ended') {
|
|
966
|
+
suspendedPhase = phase
|
|
967
|
+
endReason = turn.reason
|
|
968
|
+
endReasonDetail = turn.reasonDetail
|
|
969
|
+
endedAt = turn.at
|
|
970
|
+
phase = 'ended'
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
/*
|
|
974
|
+
* 每次真正下发前跑一遍跨字段不变量(I1–I10)。正常情况下恒为空;一旦某个
|
|
975
|
+
* 未来改动重新引入「进度与状态打架」,这里会把它连同具体字段值一起带出去,
|
|
976
|
+
* 而不是等用户截图发现。附在 payload 上而不是抛错:展示层不应因自检而白屏。
|
|
977
|
+
*/
|
|
978
|
+
const violations = aggregateInvariantViolations(snapshot, phase)
|
|
872
979
|
return {
|
|
873
980
|
sessionId: session.id,
|
|
874
981
|
planId: record.planId ?? plan?.planId ?? null,
|
|
875
982
|
title: plan?.title ?? record.title ?? '任务进度',
|
|
876
983
|
phase,
|
|
984
|
+
...(phase === 'ended' ? { suspendedPhase, endReason, endReasonDetail, endedAt } : {}),
|
|
985
|
+
...(violations.length ? { violations } : {}),
|
|
877
986
|
degraded: record.degraded === true,
|
|
878
987
|
error: record.error ? truncate(record.error, 2000) : null,
|
|
879
988
|
errors: (record.errors ?? []).slice(-20),
|
|
@@ -940,6 +1049,8 @@ export function reconstructFromSession(session: any, targetPlanId?: string) {
|
|
|
940
1049
|
* plan and let the caller ask for a specific one.
|
|
941
1050
|
*/
|
|
942
1051
|
const finished: any[] = []
|
|
1052
|
+
/** [1.6.0] callId → 待确认的 update 写入;只有配对的 tool/result 才能定案。 */
|
|
1053
|
+
const pendingToolCalls = new Map<string, any>()
|
|
943
1054
|
const snapshot = () => ({ plan, statuses, resultPhase, resultSeen, resumeCount, cancelled })
|
|
944
1055
|
for (let seq = start; seq < session.seq; seq += 1) {
|
|
945
1056
|
let event
|
|
@@ -987,19 +1098,42 @@ export function reconstructFromSession(session: any, targetPlanId?: string) {
|
|
|
987
1098
|
cancelled = true
|
|
988
1099
|
}
|
|
989
1100
|
} else if (event.type === 'tool/call' && plan) {
|
|
1101
|
+
/*
|
|
1102
|
+
* [1.6.0] 只登记,不下结论。
|
|
1103
|
+
*
|
|
1104
|
+
* 回放原来一见到 `dsh_workplan_update` 的调用就 applyStatus,但调用发起不等于
|
|
1105
|
+
* 状态写入成功:宿主的 tool/result 带着 `message.isError`。会话
|
|
1106
|
+
* session-7129faea 里就有一次失败的 update 被回放记成完成,于是刷新后面板显示
|
|
1107
|
+
* 7/12,而真实只完成 6/12 —— 回放比实时“更乐观”,这是第二个数据源打架点。
|
|
1108
|
+
* 现在一律等对应的 tool/result:成功才采纳,失败或没有结果都不采纳。
|
|
1109
|
+
*/
|
|
990
1110
|
const data = event.data
|
|
991
|
-
if (data?.name === 'dsh_workplan_update') {
|
|
1111
|
+
if (data?.name === 'dsh_workplan_update' && typeof data.callId === 'string') {
|
|
992
1112
|
const args = readToolArguments(data.arguments)
|
|
993
1113
|
if (args && typeof args.subtaskId === 'string' && typeof args.status === 'string') {
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1114
|
+
pendingToolCalls.set(data.callId, { args, planId: plan.planId })
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
} else if (event.type === 'tool/result') {
|
|
1118
|
+
const message = event.data?.message
|
|
1119
|
+
const callId = message?.role === 'tool' ? message.toolCallId : null
|
|
1120
|
+
if (typeof callId !== 'string') continue
|
|
1121
|
+
const pending = pendingToolCalls.get(callId)
|
|
1122
|
+
pendingToolCalls.delete(callId)
|
|
1123
|
+
if (!pending || !plan || pending.planId !== plan.planId) {
|
|
1124
|
+
// 结果晚于新计划到达:旧计划的写入不污染新计划的状态表。
|
|
1125
|
+
} else if (message.isError === true) {
|
|
1126
|
+
// 失败的更新不是完成:状态表保持原样。
|
|
1127
|
+
} else {
|
|
1128
|
+
const args = pending.args
|
|
1129
|
+
try {
|
|
1130
|
+
statuses = applyStatus(statuses, args.subtaskId, {
|
|
1131
|
+
status: args.status,
|
|
1132
|
+
progress: typeof args.progress === 'number' ? args.progress : undefined,
|
|
1133
|
+
summary: typeof args.note === 'string' ? args.note : undefined,
|
|
1134
|
+
})
|
|
1135
|
+
} catch {
|
|
1136
|
+
// An invalid historical call is ignored; the live cache remains authoritative.
|
|
1003
1137
|
}
|
|
1004
1138
|
}
|
|
1005
1139
|
}
|
|
@@ -1028,6 +1162,15 @@ export function reconstructFromSession(session: any, targetPlanId?: string) {
|
|
|
1028
1162
|
let phase = planPhase(statuses, resultSeen)
|
|
1029
1163
|
if (resultPhase === 'paused' && statuses.some((item: any) => item.status !== 'done')) phase = 'paused'
|
|
1030
1164
|
if (cancelled) phase = 'cancelled'
|
|
1165
|
+
/*
|
|
1166
|
+
* [1.5.2] 回放也必须走同一套对账。
|
|
1167
|
+
*
|
|
1168
|
+
* 回放曾经因为 `resultSeen=true`(结果消息在会话里)得到 done,而同一时刻的
|
|
1169
|
+
* 实时缓存因为没有这个副作用得到 integrating —— 同一个会话刷新前后翻转。
|
|
1170
|
+
* `reconcilePhase` 让两条链路都以 statuses 为准:全部完成即 done(包括历史上
|
|
1171
|
+
* 「先完成、后收到取消消息」的会话,不再出现「100% + 已取消」)。
|
|
1172
|
+
*/
|
|
1173
|
+
phase = reconcilePhase(phase, statuses)
|
|
1031
1174
|
/*
|
|
1032
1175
|
* An old plan that was never dispatched used to be relabelled `cancelled`,
|
|
1033
1176
|
* which also made it permanently unresumable — the card said "已取消" and no
|
|
@@ -1206,7 +1349,16 @@ export function createWorkplanRuntime(ctx: any, options: WorkplanRuntimeOptions
|
|
|
1206
1349
|
if (status === 'blocked') {
|
|
1207
1350
|
record.statuses = markDependentsBlocked(record.plan, record.statuses, subtaskId)
|
|
1208
1351
|
}
|
|
1209
|
-
|
|
1352
|
+
/*
|
|
1353
|
+
* [1.5.2] 这里曾是「100% + 正在收尾」的直接入口。
|
|
1354
|
+
*
|
|
1355
|
+
* 旧代码 `record.phase = planPhase(record.statuses)` 用不到 `resultSeen`,
|
|
1356
|
+
* 所以主 Agent 用 update 工具补完最后一项时得到的 phase 永远是 integrating;
|
|
1357
|
+
* 而这条路径之后不会再派发,也就永远不会写结果消息把它晋升为 done。
|
|
1358
|
+
* 走 `reconcilePhase` 后,全部完成即 done,且已 done 的计划不会被一次
|
|
1359
|
+
* 空更新打回 integrating(T4 实测 A.2 的回退路径)。
|
|
1360
|
+
*/
|
|
1361
|
+
record.phase = reconcilePhase(record.phase, record.statuses)
|
|
1210
1362
|
record.updatedAt = Date.now()
|
|
1211
1363
|
const snapshot = derivePlanSnapshot(record.statuses)
|
|
1212
1364
|
return {
|
|
@@ -1231,6 +1383,29 @@ export function createWorkplanRuntime(ctx: any, options: WorkplanRuntimeOptions
|
|
|
1231
1383
|
const reason = typeof args?.reason === 'string' && args.reason.trim()
|
|
1232
1384
|
? args.reason.trim()
|
|
1233
1385
|
: '用户取消'
|
|
1386
|
+
/*
|
|
1387
|
+
* [1.5.2] 已完成即终态:取消不再把 100% 的计划改写成「已取消」。
|
|
1388
|
+
*
|
|
1389
|
+
* T4 实测 A.4:2/2 全部 done 的计划被取消后会得到 `phase=cancelled` +
|
|
1390
|
+
* `done=2/2` + `percent=100` —— 又一个「一边说全好了、一边说被取消」的
|
|
1391
|
+
* 自相矛盾组合,且会让本可续跑/查看的完成记录永远退出「已完成」组。
|
|
1392
|
+
* 这里直接把取消变成 no-op 并如实返回 done。
|
|
1393
|
+
*/
|
|
1394
|
+
const before = derivePlanSnapshot(record.statuses)
|
|
1395
|
+
if (before.stats.total > 0 && before.stats.done === before.stats.total) {
|
|
1396
|
+
record.phase = 'done'
|
|
1397
|
+
record.updatedAt = Date.now()
|
|
1398
|
+
state.records.set(session.id, record)
|
|
1399
|
+
return {
|
|
1400
|
+
ok: true,
|
|
1401
|
+
planId: record.plan.planId,
|
|
1402
|
+
phase: 'done',
|
|
1403
|
+
reason,
|
|
1404
|
+
alreadyDone: true,
|
|
1405
|
+
abortedRunning: false,
|
|
1406
|
+
stats: before.stats,
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1234
1409
|
if (record.dispatchPromise) {
|
|
1235
1410
|
/*
|
|
1236
1411
|
* Cancel must always work.
|
|
@@ -1411,9 +1586,14 @@ export function createWorkplanRuntime(ctx: any, options: WorkplanRuntimeOptions
|
|
|
1411
1586
|
ctx.logger?.warn?.(`dsh-workplan: 写入结果消息失败:${error instanceof Error ? error.message : String(error)}`)
|
|
1412
1587
|
}
|
|
1413
1588
|
}
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1589
|
+
/*
|
|
1590
|
+
* [1.5.2] 终态不再依赖「结果消息写成功」这个副作用。
|
|
1591
|
+
*
|
|
1592
|
+
* 旧代码只有 `record.resultWritten === true` 时才把 phase 升成 done,
|
|
1593
|
+
* 于是 session.append 抛错(或该轮根本没有派发)就会永久停在 integrating。
|
|
1594
|
+
* 完成语义由 statuses 决定;结果消息只负责持久化,不负责定义完成。
|
|
1595
|
+
*/
|
|
1596
|
+
record.phase = reconcilePhase(record.phase, record.statuses)
|
|
1417
1597
|
/*
|
|
1418
1598
|
* The model's dispatch result reports the same snapshot the dashboard will
|
|
1419
1599
|
* show. It used to send `record.stats` while the status list below was read
|