dsh-workplan 1.5.0 → 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/lib/plan.js CHANGED
@@ -1039,8 +1039,18 @@ export function applyStatus(statuses, subtaskId, patch) {
1039
1039
  if (patch.error === undefined)
1040
1040
  next.error = null;
1041
1041
  }
1042
- if (patch.status === 'in_progress' && next.startedAt === null) {
1043
- next.startedAt = Date.now();
1042
+ if (patch.status === 'in_progress') {
1043
+ if (next.startedAt === null)
1044
+ next.startedAt = Date.now();
1045
+ /*
1046
+ * [1.5.2] 「正在做」不允许自称 100%。
1047
+ *
1048
+ * 工具允许 `{status:'in_progress', progress:100}`,界面于是出现
1049
+ * 「正在做 + 进度 100%」,计划层再叠一个环 0% / 加权 100%(T5 C8)。
1050
+ * 完成只能由 status='done' 表达,progress 到 99 就是这一步的上限。
1051
+ */
1052
+ if (Number(next.progress) >= 100)
1053
+ next.progress = 99;
1044
1054
  }
1045
1055
  if (patch.status === 'blocked' && next.finishedAt === null) {
1046
1056
  next.finishedAt = Date.now();
@@ -1169,6 +1179,16 @@ export function deriveAggregates(statuses) {
1169
1179
  departments.set(item.department, department);
1170
1180
  }
1171
1181
  const total = statuses.length;
1182
+ const allDone = total > 0 && done === total;
1183
+ /*
1184
+ * [1.5.2] 加权进度在「未全部完成」时不得四舍五入成 100。
1185
+ *
1186
+ * 旧算法直接 `round(progressSum / total)`:11 个 done + 1 个 blocked@95 得到
1187
+ * 100,卡片上就出现「总体进度 100%」旁边还有「1 卡住了 / 11/12 做好了」——
1188
+ * 与截图同类的自相矛盾(T4 实测 A.5)。这里的封顶只作用于展示口径,不影响
1189
+ * 任何计数;全部完成时仍严格为 100,从而与 `done === total` 一一对应。
1190
+ */
1191
+ const raw = total === 0 ? 0 : Math.round(progressSum / total);
1172
1192
  return {
1173
1193
  stats: {
1174
1194
  total,
@@ -1176,7 +1196,7 @@ export function deriveAggregates(statuses) {
1176
1196
  blocked,
1177
1197
  inProgress,
1178
1198
  pending,
1179
- progress: total === 0 ? 0 : Math.round(progressSum / total),
1199
+ progress: allDone ? 100 : Math.min(99, raw),
1180
1200
  },
1181
1201
  departments: [...departments.values()],
1182
1202
  };
@@ -1203,13 +1223,18 @@ export function computeStats(statuses) {
1203
1223
  return derivePlanSnapshot(statuses).stats;
1204
1224
  }
1205
1225
  /**
1206
- * Invariant checker for tests and CI; production paths never call it because
1207
- * pure derivation is already self-consistent.
1226
+ * Invariant checker for tests and CI.
1208
1227
  *
1209
1228
  * Returns an empty array when every invariant holds. Each message names the
1210
1229
  * field and the observed value so it can be pasted straight into a report.
1230
+ *
1231
+ * [1.5.2] `phase` 是新增的可选入参:1.4.3 的 I1–I7 只校验计数内部自洽,
1232
+ * 完全不看 `phase` 与 `stats` 的关系,因此「14/14 且 progress=100 却写着
1233
+ * 正在收尾」这种**跨字段**矛盾一路畅通到界面。I8/I9 专门封住这一类:
1234
+ * 只要调用方把本次真正要下发的 phase 传进来,任何「进度与状态文案打架」
1235
+ * 的载荷都会在这里被判为违规,而不是等到用户看截图。
1211
1236
  */
1212
- export function aggregateInvariantViolations(snapshot) {
1237
+ export function aggregateInvariantViolations(snapshot, phase) {
1213
1238
  const violations = [];
1214
1239
  const { statuses, stats, departments } = snapshot;
1215
1240
  if (stats.total !== statuses.length) {
@@ -1271,19 +1296,98 @@ export function aggregateInvariantViolations(snapshot) {
1271
1296
  if (sums.inProgress !== stats.inProgress) {
1272
1297
  violations.push(`I7 各部门 inProgress 之和=${sums.inProgress} 与 stats.inProgress=${stats.inProgress} 不一致`);
1273
1298
  }
1299
+ /*
1300
+ * [1.5.2] I8/I9 —— 完成语义跨字段不变量(用户截图那条「100% + 正在收尾」)。
1301
+ *
1302
+ * I8:工作项全部 done ⇔ phase 必须是 done。这是一条双向断言:
1303
+ * - 全 done 却是 pending/planning/executing/integrating/paused/blocked/degraded
1304
+ * ⇒ 卡片会同时出现 100% 与进行中文案(本次事故);
1305
+ * - phase=done 却还有未完成项 ⇒ 反向矛盾(「已完成」下面挂着没做完的活)。
1306
+ * I9:加权进度与计数同口径:全 done ⇒ 100;未全 done ⇒ 不得为 100。
1307
+ * 旧算法 round(progressSum/total) 让 11 done + 1 blocked@95 算出 100。
1308
+ */
1309
+ if (stats.total > 0) {
1310
+ const allDone = stats.done === stats.total;
1311
+ if (allDone && phase !== undefined && phase !== null && phase !== 'done') {
1312
+ violations.push(`I8 stats.done=${stats.done}/${stats.total} 已全部完成,但 phase=${String(phase)} 仍表示未完成`);
1313
+ }
1314
+ if (!allDone && phase === 'done') {
1315
+ violations.push(`I8 phase=done 但 stats.done=${stats.done}/${stats.total} 尚未全部完成`);
1316
+ }
1317
+ if (allDone && stats.progress !== 100) {
1318
+ violations.push(`I9 全部完成但 stats.progress=${stats.progress}`);
1319
+ }
1320
+ if (!allDone && stats.progress >= 100) {
1321
+ violations.push(`I9 未全部完成(${stats.done}/${stats.total})却显示 stats.progress=${stats.progress}`);
1322
+ }
1323
+ }
1324
+ /*
1325
+ * I10:单个工作项也不能自相矛盾——`in_progress` 意味着「还没做完」,
1326
+ * 它的进度必须 < 100(旧载荷里出现过「正在做 + 100%」,T5 C8)。
1327
+ */
1328
+ for (const item of statuses) {
1329
+ if (item?.status === 'in_progress' && Number(item?.progress) >= 100) {
1330
+ violations.push(`I10 子任务 ${String(item?.subtaskId)} 状态为 in_progress 但 progress=${item?.progress}`);
1331
+ }
1332
+ }
1274
1333
  return violations;
1275
1334
  }
1276
- export function planPhase(statuses, resultSeen = false) {
1335
+ /**
1336
+ * 计划级阶段,由状态列表唯一决定。
1337
+ *
1338
+ * [1.5.2] 修复「一边 100%、一边正在收尾」的根因。
1339
+ *
1340
+ * 旧实现的最后一步是 `return resultSeen ? 'done' : 'integrating'`:工作项全部
1341
+ * done 时仍要等「结果消息落盘」才晋升终态。问题在于 `resultSeen` 只是**持久化
1342
+ * 回放**才知道的副作用,内存路径(`updateStatus` / `serialisePlan`)拿不到它:
1343
+ *
1344
+ * - 主 Agent 用 `dsh_workplan_update` 补完最后几项时,phase 永远停在 integrating;
1345
+ * - 该路径不会再派发,也就永远不会写结果消息,于是 integrating 永久留存;
1346
+ * - 同期 `stats.done/total` 已是 14/14、百分比 100%,卡片就同时显示
1347
+ * 「100% · 都做完了」和「正在收尾」,并留在「进行中」组(用户截图)。
1348
+ *
1349
+ * 结论:完成语义只能有一个事实源——状态列表。工作项全部完成即计划完成,
1350
+ * 不需要也不允许等另一个副作用来「确认」。`resultSeen` 参数仅为兼容旧调用点保留,
1351
+ * 不再参与判定(旧的 integrating 窗口已由 dispatch 内的 `executing` 覆盖)。
1352
+ */
1353
+ export function planPhase(statuses, _resultSeen = false) {
1277
1354
  if (statuses.length === 0)
1278
1355
  return 'pending';
1279
1356
  if (statuses.every((item) => item.status === 'done'))
1280
- return resultSeen ? 'done' : 'integrating';
1357
+ return 'done';
1281
1358
  if (statuses.some((item) => item.status === 'blocked'))
1282
1359
  return 'blocked';
1283
1360
  if (statuses.some((item) => item.status === 'in_progress' || item.status === 'done'))
1284
1361
  return 'executing';
1285
1362
  return 'pending';
1286
1363
  }
1364
+ /**
1365
+ * 把「缓存的 phase」与「由状态列表派生的 phase」对账成唯一结果。
1366
+ *
1367
+ * phase 是缓存(dispatch/预算/取消会写它),statuses 是事实源。只要两者可能
1368
+ * 分叉,就必须在出口处对账,否则同一个会话会因读取路径不同而翻转(实时
1369
+ * integrating / 刷新后 done,T2 场景 B)。
1370
+ *
1371
+ * 规则只有四条,全部可由 `aggregateInvariantViolations` 断言:
1372
+ * 1. 工作项全部 done ⇒ 一定是 done(缓存不得把它拖回任何进行中状态);
1373
+ * 2. 缓存是 done/integrating 但工作项没全 done ⇒ 缓存过期,回退到派生值;
1374
+ * 3. pending/planning/executing 是「状态列表能直接回答」的轮次状态:一旦
1375
+ * 有工作项开始(派生值不再是 pending),缓存里的旧 pending/planning 必须
1376
+ * 让位;而派生值仍是 pending 时保留缓存(dispatch 刚开始、状态尚未落盘)。
1377
+ * 4. 其余情况保留缓存的轮次状态(paused/cancelled/blocked/degraded)——
1378
+ * 它们携带状态列表里没有的信息(预算暂停、用户取消)。
1379
+ */
1380
+ export function reconcilePhase(phase, statuses) {
1381
+ const derived = planPhase(statuses);
1382
+ if (derived === 'done')
1383
+ return 'done';
1384
+ if (phase === 'done' || phase === 'integrating')
1385
+ return derived;
1386
+ if (phase === 'pending' || phase === 'planning' || phase === 'executing') {
1387
+ return derived === 'pending' ? phase : derived;
1388
+ }
1389
+ return phase ?? derived;
1390
+ }
1287
1391
  export function nextReadyInStage(plan, statuses, stageId) {
1288
1392
  const byId = new Map(statuses.map((item) => [item.subtaskId, item]));
1289
1393
  const stage = plan.stages.find((item) => item.id === stageId);
package/lib/runtime.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * in this file is allowed to escape as an unhandled rejection from a listener.
13
13
  */
14
14
  import { randomUUID } from 'node:crypto';
15
- import { MAX_CONCURRENT, PlanError, applyStatus, attachPlanMeta, buildChildPrompt, buildCancelMessage, buildDegradedMessage, buildPlanMessage, buildPlannerPrompt, buildResultMessage, createStatuses, derivePlanSnapshot, extractJson, flattenSubtasks, isRecord, isSocialMessage, isStatusQuestion, isVagueContinuation, markDependentsBlocked, messageText, readySubtasks, MAX_PLAN_RESUMES, canResumePlan, resetBlockedStatuses, parseCancelMessage, parseResultMessage, parseWorkplanMessage, planPhase, truncate, validatePlan, applyResultStatuses, } from './plan.js';
15
+ import { MAX_CONCURRENT, PlanError, aggregateInvariantViolations, applyStatus, attachPlanMeta, buildChildPrompt, buildCancelMessage, buildDegradedMessage, buildPlanMessage, buildPlannerPrompt, buildResultMessage, createStatuses, derivePlanSnapshot, extractJson, flattenSubtasks, isRecord, isSocialMessage, isStatusQuestion, isVagueContinuation, markDependentsBlocked, messageText, readySubtasks, MAX_PLAN_RESUMES, canResumePlan, resetBlockedStatuses, parseCancelMessage, parseResultMessage, parseWorkplanMessage, planPhase, reconcilePhase, truncate, validatePlan, applyResultStatuses, } from './plan.js';
16
16
  import { createProgressWatchdog, describeError } from './watchdog.js';
17
17
  export const PLANNER_TIMEOUT_MS = 120000;
18
18
  /**
@@ -785,21 +785,31 @@ export async function dispatchPlan(ctx, record, options) {
785
785
  });
786
786
  }
787
787
  record.statuses = reset;
788
- record.phase = 'paused';
789
788
  const total = record.statuses.length;
790
789
  const done = record.statuses.filter((item) => item.status === 'done').length;
790
+ /*
791
+ * [1.5.2] 预算到点 ≠ 一定是「暂停」。
792
+ *
793
+ * 旧代码在这里无条件 `record.phase = 'paused'`:如果最后一个子任务恰好在
794
+ * 截止时刻之后完成(done === total),计划会以 14/14、progress=100 的载荷
795
+ * 带着 paused 返回,界面就是「100% + 预算用完,可续跑」(T5 C4)。
796
+ * 全部完成时直接进终态,并且不再写「其余 0 项保留为未完成」这种自相矛盾的说明。
797
+ */
798
+ const allDoneNow = total > 0 && done === total;
799
+ record.phase = allDoneNow ? 'done' : 'paused';
791
800
  const minutes = Math.max(1, Math.round((options.totalTimeoutMs ?? TOTAL_TIMEOUT_MS) / 60000));
792
- record.error = `本轮执行预算(${minutes} 分钟)已到:已完成 ${done}/${total},其余 ${total - done} 项保留为未完成,再次派发本计划即可续跑(已完成的不重跑)`;
801
+ record.error = allDoneNow
802
+ ? null
803
+ : `本轮执行预算(${minutes} 分钟)已到:已完成 ${done}/${total},其余 ${total - done} 项保留为未完成,再次派发本计划即可续跑(已完成的不重跑)`;
793
804
  /*
794
805
  * Drop the failure lines the aborted children wrote on their way out.
795
806
  * They used to survive into the result payload as
796
807
  * `["T4: 总执行超时", "总执行超时", …]`, so the model read a failure
797
808
  * report for subtasks that were explicitly *not* failures.
798
809
  */
799
- record.errors = [
800
- ...(record.errors ?? []).filter((line) => !/总执行超时/.test(String(line))),
801
- record.error,
802
- ].slice(-20);
810
+ record.errors = (record.errors ?? []).filter((line) => !/总执行超时/.test(String(line)));
811
+ if (!allDoneNow)
812
+ record.errors = [...record.errors, record.error].slice(-20);
803
813
  }
804
814
  else {
805
815
  /*
@@ -850,15 +860,30 @@ function serialisePlan(record, session) {
850
860
  : []);
851
861
  const statuses = snapshot.statuses;
852
862
  const stats = snapshot.stats;
853
- let phase = record.phase ?? (plan ? planPhase(statuses) : 'degraded');
863
+ /*
864
+ * [1.5.2] 出口对账,而不是无条件相信内存缓存。
865
+ *
866
+ * 旧写法 `record.phase ?? planPhase(statuses)` 让缓存一旦存在就完全绕过
867
+ * statuses:一次 `updateStatus` 把计划写成 integrating 之后,即使 14/14 全部
868
+ * 完成,下发的 phase 仍是 integrating,界面于是「100% + 正在收尾」。这里改成
869
+ * `reconcilePhase`:全部完成一律 done,缓存过期的 done/integrating 回退到派生值。
870
+ */
871
+ let phase = reconcilePhase(record.phase ?? (plan ? planPhase(statuses) : 'degraded'), statuses);
854
872
  if (phase === 'pending' && !record.dispatchedAt && plan?.createdAt && Date.now() - plan.createdAt > STALE_PENDING_MS) {
855
873
  phase = 'cancelled';
856
874
  }
875
+ /*
876
+ * 每次真正下发前跑一遍跨字段不变量(I1–I10)。正常情况下恒为空;一旦某个
877
+ * 未来改动重新引入「进度与状态打架」,这里会把它连同具体字段值一起带出去,
878
+ * 而不是等用户截图发现。附在 payload 上而不是抛错:展示层不应因自检而白屏。
879
+ */
880
+ const violations = aggregateInvariantViolations(snapshot, phase);
857
881
  return {
858
882
  sessionId: session.id,
859
883
  planId: record.planId ?? plan?.planId ?? null,
860
884
  title: plan?.title ?? record.title ?? '任务进度',
861
885
  phase,
886
+ ...(violations.length ? { violations } : {}),
862
887
  degraded: record.degraded === true,
863
888
  error: record.error ? truncate(record.error, 2000) : null,
864
889
  errors: (record.errors ?? []).slice(-20),
@@ -1024,6 +1049,15 @@ export function reconstructFromSession(session, targetPlanId) {
1024
1049
  phase = 'paused';
1025
1050
  if (cancelled)
1026
1051
  phase = 'cancelled';
1052
+ /*
1053
+ * [1.5.2] 回放也必须走同一套对账。
1054
+ *
1055
+ * 回放曾经因为 `resultSeen=true`(结果消息在会话里)得到 done,而同一时刻的
1056
+ * 实时缓存因为没有这个副作用得到 integrating —— 同一个会话刷新前后翻转。
1057
+ * `reconcilePhase` 让两条链路都以 statuses 为准:全部完成即 done(包括历史上
1058
+ * 「先完成、后收到取消消息」的会话,不再出现「100% + 已取消」)。
1059
+ */
1060
+ phase = reconcilePhase(phase, statuses);
1027
1061
  /*
1028
1062
  * An old plan that was never dispatched used to be relabelled `cancelled`,
1029
1063
  * which also made it permanently unresumable — the card said "已取消" and no
@@ -1195,7 +1229,16 @@ export function createWorkplanRuntime(ctx, options = {}) {
1195
1229
  if (status === 'blocked') {
1196
1230
  record.statuses = markDependentsBlocked(record.plan, record.statuses, subtaskId);
1197
1231
  }
1198
- record.phase = planPhase(record.statuses);
1232
+ /*
1233
+ * [1.5.2] 这里曾是「100% + 正在收尾」的直接入口。
1234
+ *
1235
+ * 旧代码 `record.phase = planPhase(record.statuses)` 用不到 `resultSeen`,
1236
+ * 所以主 Agent 用 update 工具补完最后一项时得到的 phase 永远是 integrating;
1237
+ * 而这条路径之后不会再派发,也就永远不会写结果消息把它晋升为 done。
1238
+ * 走 `reconcilePhase` 后,全部完成即 done,且已 done 的计划不会被一次
1239
+ * 空更新打回 integrating(T4 实测 A.2 的回退路径)。
1240
+ */
1241
+ record.phase = reconcilePhase(record.phase, record.statuses);
1199
1242
  record.updatedAt = Date.now();
1200
1243
  const snapshot = derivePlanSnapshot(record.statuses);
1201
1244
  return {
@@ -1223,6 +1266,29 @@ export function createWorkplanRuntime(ctx, options = {}) {
1223
1266
  const reason = typeof args?.reason === 'string' && args.reason.trim()
1224
1267
  ? args.reason.trim()
1225
1268
  : '用户取消';
1269
+ /*
1270
+ * [1.5.2] 已完成即终态:取消不再把 100% 的计划改写成「已取消」。
1271
+ *
1272
+ * T4 实测 A.4:2/2 全部 done 的计划被取消后会得到 `phase=cancelled` +
1273
+ * `done=2/2` + `percent=100` —— 又一个「一边说全好了、一边说被取消」的
1274
+ * 自相矛盾组合,且会让本可续跑/查看的完成记录永远退出「已完成」组。
1275
+ * 这里直接把取消变成 no-op 并如实返回 done。
1276
+ */
1277
+ const before = derivePlanSnapshot(record.statuses);
1278
+ if (before.stats.total > 0 && before.stats.done === before.stats.total) {
1279
+ record.phase = 'done';
1280
+ record.updatedAt = Date.now();
1281
+ state.records.set(session.id, record);
1282
+ return {
1283
+ ok: true,
1284
+ planId: record.plan.planId,
1285
+ phase: 'done',
1286
+ reason,
1287
+ alreadyDone: true,
1288
+ abortedRunning: false,
1289
+ stats: before.stats,
1290
+ };
1291
+ }
1226
1292
  if (record.dispatchPromise) {
1227
1293
  /*
1228
1294
  * Cancel must always work.
@@ -1409,9 +1475,14 @@ export function createWorkplanRuntime(ctx, options = {}) {
1409
1475
  ctx.logger?.warn?.(`dsh-workplan: 写入结果消息失败:${error instanceof Error ? error.message : String(error)}`);
1410
1476
  }
1411
1477
  }
1412
- if (record.resultWritten && record.statuses.length > 0 && record.statuses.every((item) => item.status === 'done')) {
1413
- record.phase = 'done';
1414
- }
1478
+ /*
1479
+ * [1.5.2] 终态不再依赖「结果消息写成功」这个副作用。
1480
+ *
1481
+ * 旧代码只有 `record.resultWritten === true` 时才把 phase 升成 done,
1482
+ * 于是 session.append 抛错(或该轮根本没有派发)就会永久停在 integrating。
1483
+ * 完成语义由 statuses 决定;结果消息只负责持久化,不负责定义完成。
1484
+ */
1485
+ record.phase = reconcilePhase(record.phase, record.statuses);
1415
1486
  /*
1416
1487
  * The model's dispatch result reports the same snapshot the dashboard will
1417
1488
  * show. It used to send `record.stats` while the status list below was read
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-workplan",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Automatic task decomposition, real department subagents, and a responsive workplan dashboard for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "license": "MIT",