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/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 = `本轮执行预算(${minutes} 分钟)已到:已完成 ${done}/${total},其余 ${total - done} 项保留为未完成,再次派发本计划即可续跑(已完成的不重跑)`
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
- ...(record.errors ?? []).filter((line: string) => !/总执行超时/.test(String(line))),
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
@@ -865,15 +876,30 @@ function serialisePlan(record: any, session: any) {
865
876
  )
866
877
  const statuses = snapshot.statuses
867
878
  const stats = snapshot.stats
868
- let phase = record.phase ?? (plan ? planPhase(statuses) : 'degraded')
879
+ /*
880
+ * [1.5.2] 出口对账,而不是无条件相信内存缓存。
881
+ *
882
+ * 旧写法 `record.phase ?? planPhase(statuses)` 让缓存一旦存在就完全绕过
883
+ * statuses:一次 `updateStatus` 把计划写成 integrating 之后,即使 14/14 全部
884
+ * 完成,下发的 phase 仍是 integrating,界面于是「100% + 正在收尾」。这里改成
885
+ * `reconcilePhase`:全部完成一律 done,缓存过期的 done/integrating 回退到派生值。
886
+ */
887
+ let phase = reconcilePhase(record.phase ?? (plan ? planPhase(statuses) : 'degraded'), statuses)
869
888
  if (phase === 'pending' && !record.dispatchedAt && plan?.createdAt && Date.now() - plan.createdAt > STALE_PENDING_MS) {
870
889
  phase = 'cancelled'
871
890
  }
891
+ /*
892
+ * 每次真正下发前跑一遍跨字段不变量(I1–I10)。正常情况下恒为空;一旦某个
893
+ * 未来改动重新引入「进度与状态打架」,这里会把它连同具体字段值一起带出去,
894
+ * 而不是等用户截图发现。附在 payload 上而不是抛错:展示层不应因自检而白屏。
895
+ */
896
+ const violations = aggregateInvariantViolations(snapshot, phase)
872
897
  return {
873
898
  sessionId: session.id,
874
899
  planId: record.planId ?? plan?.planId ?? null,
875
900
  title: plan?.title ?? record.title ?? '任务进度',
876
901
  phase,
902
+ ...(violations.length ? { violations } : {}),
877
903
  degraded: record.degraded === true,
878
904
  error: record.error ? truncate(record.error, 2000) : null,
879
905
  errors: (record.errors ?? []).slice(-20),
@@ -1028,6 +1054,15 @@ export function reconstructFromSession(session: any, targetPlanId?: string) {
1028
1054
  let phase = planPhase(statuses, resultSeen)
1029
1055
  if (resultPhase === 'paused' && statuses.some((item: any) => item.status !== 'done')) phase = 'paused'
1030
1056
  if (cancelled) phase = 'cancelled'
1057
+ /*
1058
+ * [1.5.2] 回放也必须走同一套对账。
1059
+ *
1060
+ * 回放曾经因为 `resultSeen=true`(结果消息在会话里)得到 done,而同一时刻的
1061
+ * 实时缓存因为没有这个副作用得到 integrating —— 同一个会话刷新前后翻转。
1062
+ * `reconcilePhase` 让两条链路都以 statuses 为准:全部完成即 done(包括历史上
1063
+ * 「先完成、后收到取消消息」的会话,不再出现「100% + 已取消」)。
1064
+ */
1065
+ phase = reconcilePhase(phase, statuses)
1031
1066
  /*
1032
1067
  * An old plan that was never dispatched used to be relabelled `cancelled`,
1033
1068
  * which also made it permanently unresumable — the card said "已取消" and no
@@ -1206,7 +1241,16 @@ export function createWorkplanRuntime(ctx: any, options: WorkplanRuntimeOptions
1206
1241
  if (status === 'blocked') {
1207
1242
  record.statuses = markDependentsBlocked(record.plan, record.statuses, subtaskId)
1208
1243
  }
1209
- record.phase = planPhase(record.statuses)
1244
+ /*
1245
+ * [1.5.2] 这里曾是「100% + 正在收尾」的直接入口。
1246
+ *
1247
+ * 旧代码 `record.phase = planPhase(record.statuses)` 用不到 `resultSeen`,
1248
+ * 所以主 Agent 用 update 工具补完最后一项时得到的 phase 永远是 integrating;
1249
+ * 而这条路径之后不会再派发,也就永远不会写结果消息把它晋升为 done。
1250
+ * 走 `reconcilePhase` 后,全部完成即 done,且已 done 的计划不会被一次
1251
+ * 空更新打回 integrating(T4 实测 A.2 的回退路径)。
1252
+ */
1253
+ record.phase = reconcilePhase(record.phase, record.statuses)
1210
1254
  record.updatedAt = Date.now()
1211
1255
  const snapshot = derivePlanSnapshot(record.statuses)
1212
1256
  return {
@@ -1231,6 +1275,29 @@ export function createWorkplanRuntime(ctx: any, options: WorkplanRuntimeOptions
1231
1275
  const reason = typeof args?.reason === 'string' && args.reason.trim()
1232
1276
  ? args.reason.trim()
1233
1277
  : '用户取消'
1278
+ /*
1279
+ * [1.5.2] 已完成即终态:取消不再把 100% 的计划改写成「已取消」。
1280
+ *
1281
+ * T4 实测 A.4:2/2 全部 done 的计划被取消后会得到 `phase=cancelled` +
1282
+ * `done=2/2` + `percent=100` —— 又一个「一边说全好了、一边说被取消」的
1283
+ * 自相矛盾组合,且会让本可续跑/查看的完成记录永远退出「已完成」组。
1284
+ * 这里直接把取消变成 no-op 并如实返回 done。
1285
+ */
1286
+ const before = derivePlanSnapshot(record.statuses)
1287
+ if (before.stats.total > 0 && before.stats.done === before.stats.total) {
1288
+ record.phase = 'done'
1289
+ record.updatedAt = Date.now()
1290
+ state.records.set(session.id, record)
1291
+ return {
1292
+ ok: true,
1293
+ planId: record.plan.planId,
1294
+ phase: 'done',
1295
+ reason,
1296
+ alreadyDone: true,
1297
+ abortedRunning: false,
1298
+ stats: before.stats,
1299
+ }
1300
+ }
1234
1301
  if (record.dispatchPromise) {
1235
1302
  /*
1236
1303
  * Cancel must always work.
@@ -1411,9 +1478,14 @@ export function createWorkplanRuntime(ctx: any, options: WorkplanRuntimeOptions
1411
1478
  ctx.logger?.warn?.(`dsh-workplan: 写入结果消息失败:${error instanceof Error ? error.message : String(error)}`)
1412
1479
  }
1413
1480
  }
1414
- if (record.resultWritten && record.statuses.length > 0 && record.statuses.every((item: any) => item.status === 'done')) {
1415
- record.phase = 'done'
1416
- }
1481
+ /*
1482
+ * [1.5.2] 终态不再依赖「结果消息写成功」这个副作用。
1483
+ *
1484
+ * 旧代码只有 `record.resultWritten === true` 时才把 phase 升成 done,
1485
+ * 于是 session.append 抛错(或该轮根本没有派发)就会永久停在 integrating。
1486
+ * 完成语义由 statuses 决定;结果消息只负责持久化,不负责定义完成。
1487
+ */
1488
+ record.phase = reconcilePhase(record.phase, record.statuses)
1417
1489
  /*
1418
1490
  * The model's dispatch result reports the same snapshot the dashboard will
1419
1491
  * show. It used to send `record.stats` while the status list below was read