dsh-workplan 0.0.0-stage → 1.4.3
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 +282 -0
- package/LICENSE +21 -0
- package/README.en.md +147 -0
- package/README.md +161 -2
- package/client.js +1893 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +7 -0
- package/lib/host.js +301 -0
- package/lib/plan.js +1495 -0
- package/lib/runtime.js +1755 -0
- package/lib/watchdog.js +184 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +74 -4
- package/src/client.ts +2196 -0
- package/src/globals.d.ts +14 -0
- package/src/host.ts +333 -0
- package/src/plan.ts +1591 -0
- package/src/runtime.ts +1749 -0
- package/src/watchdog.ts +207 -0
- package/tsconfig.json +36 -0
- package/vendor/chart.umd.js +14 -0
package/lib/plan.js
ADDED
|
@@ -0,0 +1,1495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning helpers for dsh-workplan.
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately has no dependency on DeepSeek Harness services. It
|
|
5
|
+
* owns the plan contract, JSON extraction/validation, the planner prompt, the
|
|
6
|
+
* durable message format, and the pure status/graph helpers used by the host
|
|
7
|
+
* runtime and by unit tests.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
export const PLAN_VERSION = 1;
|
|
11
|
+
export const MAX_STAGES = 6;
|
|
12
|
+
export const MAX_SUBTASKS = 15;
|
|
13
|
+
export const MAX_CONCURRENT = 5;
|
|
14
|
+
export const MAX_PLAN_JSON_CHARS = 60000;
|
|
15
|
+
export const PLAN_MARKER_BEGIN = '<!-- DSH-WORKPLAN:BEGIN -->';
|
|
16
|
+
export const PLAN_MARKER_END = '<!-- DSH-WORKPLAN:END -->';
|
|
17
|
+
export const RESULT_MARKER_BEGIN = '<!-- DSH-WORKPLAN-RESULT:BEGIN -->';
|
|
18
|
+
export const RESULT_MARKER_END = '<!-- DSH-WORKPLAN-RESULT:END -->';
|
|
19
|
+
export const CANCEL_MARKER_BEGIN = '<!-- DSH-WORKPLAN-CANCEL:BEGIN -->';
|
|
20
|
+
export const CANCEL_MARKER_END = '<!-- DSH-WORKPLAN-CANCEL:END -->';
|
|
21
|
+
export const STATUSES = Object.freeze(['pending', 'in_progress', 'done', 'blocked']);
|
|
22
|
+
export const LIKELIHOODS = Object.freeze(['low', 'medium', 'high']);
|
|
23
|
+
export const DEPARTMENTS = Object.freeze({
|
|
24
|
+
engineering: { zh: '工程研发', en: 'Engineering' },
|
|
25
|
+
research: { zh: '研究分析', en: 'Research' },
|
|
26
|
+
design: { zh: '设计', en: 'Design' },
|
|
27
|
+
product: { zh: '产品', en: 'Product' },
|
|
28
|
+
data: { zh: '数据', en: 'Data' },
|
|
29
|
+
qa: { zh: '质量验证', en: 'QA' },
|
|
30
|
+
security: { zh: '安全', en: 'Security' },
|
|
31
|
+
devops: { zh: '运维', en: 'DevOps' },
|
|
32
|
+
writing: { zh: '文档写作', en: 'Writing' },
|
|
33
|
+
operations: { zh: '运营', en: 'Operations' },
|
|
34
|
+
legal: { zh: '法务合规', en: 'Legal' },
|
|
35
|
+
finance: { zh: '财务', en: 'Finance' },
|
|
36
|
+
});
|
|
37
|
+
export const DEPARTMENT_IDS = Object.freeze(Object.keys(DEPARTMENTS));
|
|
38
|
+
export class PlanError extends Error {
|
|
39
|
+
code;
|
|
40
|
+
details;
|
|
41
|
+
constructor(message, code = 'PLAN_ERROR', details = undefined) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = 'PlanError';
|
|
44
|
+
this.code = code;
|
|
45
|
+
this.details = details;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function isRecord(value) {
|
|
49
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
export function truncate(value, max) {
|
|
52
|
+
if (typeof value !== 'string')
|
|
53
|
+
return '';
|
|
54
|
+
return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1))}…`;
|
|
55
|
+
}
|
|
56
|
+
export function messageText(message) {
|
|
57
|
+
if (!message || !Array.isArray(message.content))
|
|
58
|
+
return '';
|
|
59
|
+
return message.content
|
|
60
|
+
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
|
|
61
|
+
.map((block) => block.text)
|
|
62
|
+
.join('\n');
|
|
63
|
+
}
|
|
64
|
+
/*
|
|
65
|
+
* Lightweight conversational inputs must not start the planning pipeline.
|
|
66
|
+
*
|
|
67
|
+
* This is deliberately a conservative, deterministic token list rather than a
|
|
68
|
+
* model call: a greeting should never pay for a planner request or spawn
|
|
69
|
+
* department agents. The check only returns true when EVERY meaningful token is
|
|
70
|
+
* a known greeting/thanks/acknowledgement/farewell. "你好,帮我写代码" keeps
|
|
71
|
+
* the task text and therefore still gets a plan.
|
|
72
|
+
*/
|
|
73
|
+
const SOCIAL_PHRASES = Object.freeze([
|
|
74
|
+
'thank you very much',
|
|
75
|
+
'nice to meet you',
|
|
76
|
+
'how are you doing',
|
|
77
|
+
'have a nice day',
|
|
78
|
+
'see you later',
|
|
79
|
+
'good afternoon',
|
|
80
|
+
'good morning',
|
|
81
|
+
'good evening',
|
|
82
|
+
"how's it going",
|
|
83
|
+
'hello there',
|
|
84
|
+
'hi there',
|
|
85
|
+
'hey there',
|
|
86
|
+
'thank you',
|
|
87
|
+
'see you',
|
|
88
|
+
'good night',
|
|
89
|
+
'what\'s up',
|
|
90
|
+
'thanks a lot',
|
|
91
|
+
'goodbye',
|
|
92
|
+
'bye bye',
|
|
93
|
+
'谢谢你',
|
|
94
|
+
'谢谢你了',
|
|
95
|
+
'辛苦了',
|
|
96
|
+
'麻烦你了',
|
|
97
|
+
'好久不见',
|
|
98
|
+
'最近怎么样',
|
|
99
|
+
'最近好吗',
|
|
100
|
+
'很高兴认识你',
|
|
101
|
+
'早上好',
|
|
102
|
+
'上午好',
|
|
103
|
+
'中午好',
|
|
104
|
+
'下午好',
|
|
105
|
+
'晚上好',
|
|
106
|
+
'晚安',
|
|
107
|
+
'早安',
|
|
108
|
+
'午安',
|
|
109
|
+
'早点休息',
|
|
110
|
+
'亲爱的',
|
|
111
|
+
'大家好',
|
|
112
|
+
'你好',
|
|
113
|
+
'您好',
|
|
114
|
+
'哈喽',
|
|
115
|
+
'请问',
|
|
116
|
+
'在吗',
|
|
117
|
+
'在么',
|
|
118
|
+
'谢谢',
|
|
119
|
+
'多谢',
|
|
120
|
+
'再见',
|
|
121
|
+
'拜拜',
|
|
122
|
+
'好的',
|
|
123
|
+
'收到',
|
|
124
|
+
'明白了',
|
|
125
|
+
'没问题',
|
|
126
|
+
'不客气',
|
|
127
|
+
'没关系',
|
|
128
|
+
'hello',
|
|
129
|
+
'thanks',
|
|
130
|
+
'thank you',
|
|
131
|
+
'good morning',
|
|
132
|
+
'good night',
|
|
133
|
+
'see you',
|
|
134
|
+
'okay',
|
|
135
|
+
'ok',
|
|
136
|
+
'yes',
|
|
137
|
+
'yep',
|
|
138
|
+
'sure',
|
|
139
|
+
'cool',
|
|
140
|
+
'great',
|
|
141
|
+
'nice',
|
|
142
|
+
'lol',
|
|
143
|
+
'hey',
|
|
144
|
+
'hi',
|
|
145
|
+
'yo',
|
|
146
|
+
'sup',
|
|
147
|
+
'thx',
|
|
148
|
+
'bye',
|
|
149
|
+
'嗯嗯',
|
|
150
|
+
'哈哈',
|
|
151
|
+
'嘿嘿',
|
|
152
|
+
'呵呵',
|
|
153
|
+
'老师',
|
|
154
|
+
'同学',
|
|
155
|
+
'朋友',
|
|
156
|
+
'大佬',
|
|
157
|
+
'老板',
|
|
158
|
+
'先生',
|
|
159
|
+
'女士',
|
|
160
|
+
'好',
|
|
161
|
+
'行',
|
|
162
|
+
'可以',
|
|
163
|
+
'嗯',
|
|
164
|
+
'哦',
|
|
165
|
+
'噢',
|
|
166
|
+
'嗨',
|
|
167
|
+
'嘿',
|
|
168
|
+
]);
|
|
169
|
+
const SOCIAL_TRAILING_PARTICLES = /[呀啊呢吧啦哦噢嗯哈嘛喽咯哒滴了喔唔嘿呵吗]+$/u;
|
|
170
|
+
export function isSocialMessage(value) {
|
|
171
|
+
if (typeof value !== 'string')
|
|
172
|
+
return false;
|
|
173
|
+
const original = value.trim();
|
|
174
|
+
if (original.length === 0 || original.length > 60)
|
|
175
|
+
return false;
|
|
176
|
+
let rest = original.toLowerCase();
|
|
177
|
+
for (const phrase of SOCIAL_PHRASES) {
|
|
178
|
+
if (!rest.includes(phrase))
|
|
179
|
+
continue;
|
|
180
|
+
rest = rest.split(phrase).join(' ');
|
|
181
|
+
}
|
|
182
|
+
rest = rest.replace(/[\s\p{P}\p{S}]+/gu, '');
|
|
183
|
+
rest = rest.replace(SOCIAL_TRAILING_PARTICLES, '');
|
|
184
|
+
return rest.length === 0;
|
|
185
|
+
}
|
|
186
|
+
/*
|
|
187
|
+
* A bare "continue" carries no new task content. The planner only sees the
|
|
188
|
+
* current message, so it cannot know what to continue; planning it produces a
|
|
189
|
+
* huge plan for an unknown object. The main Agent has the conversation history
|
|
190
|
+
* and should continue directly (or ask which thread), so this gate bypasses the
|
|
191
|
+
* planner exactly like a social message.
|
|
192
|
+
*/
|
|
193
|
+
const VAGUE_CONTINUATIONS = Object.freeze([
|
|
194
|
+
'continue',
|
|
195
|
+
'keep going',
|
|
196
|
+
'carry on',
|
|
197
|
+
'go on',
|
|
198
|
+
'proceed',
|
|
199
|
+
'继续推进',
|
|
200
|
+
'继续做',
|
|
201
|
+
'继续吧',
|
|
202
|
+
'接着来',
|
|
203
|
+
'接着做',
|
|
204
|
+
'往下推进',
|
|
205
|
+
'往下做',
|
|
206
|
+
'然后呢',
|
|
207
|
+
'接下来',
|
|
208
|
+
'下一步',
|
|
209
|
+
'继续',
|
|
210
|
+
'接着',
|
|
211
|
+
'往下',
|
|
212
|
+
'再来',
|
|
213
|
+
'开始吧',
|
|
214
|
+
'干吧',
|
|
215
|
+
'next',
|
|
216
|
+
]);
|
|
217
|
+
export function isVagueContinuation(value) {
|
|
218
|
+
if (typeof value !== 'string')
|
|
219
|
+
return false;
|
|
220
|
+
const original = value.trim();
|
|
221
|
+
if (original.length === 0 || original.length > 24)
|
|
222
|
+
return false;
|
|
223
|
+
let rest = original.toLowerCase();
|
|
224
|
+
for (const phrase of VAGUE_CONTINUATIONS) {
|
|
225
|
+
if (!rest.includes(phrase))
|
|
226
|
+
continue;
|
|
227
|
+
rest = rest.split(phrase).join(' ');
|
|
228
|
+
}
|
|
229
|
+
rest = rest.replace(/[\s\p{P}\p{S}]+/gu, '');
|
|
230
|
+
if (rest.length === 0)
|
|
231
|
+
return true;
|
|
232
|
+
/*
|
|
233
|
+
* "B继续" / "按B继续" / "方案B继续" continue a decision already made in the
|
|
234
|
+
* conversation. The main Agent can see that decision; the planner only sees
|
|
235
|
+
* this short message, so planning it invents a huge new task. Require a short
|
|
236
|
+
* tail so "继续修复这个 bug" is still planned.
|
|
237
|
+
*/
|
|
238
|
+
const compact = original.toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '');
|
|
239
|
+
if (/继续|接着|往下|下一步/.test(compact)) {
|
|
240
|
+
const tail = compact.replace(/继续|接着|往下|下一步|方案|按照|按|就|吧|呀|啊|呢/g, '');
|
|
241
|
+
if (tail.length <= 2)
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
/*
|
|
247
|
+
* A status question asks about work already recorded; planning and dispatching
|
|
248
|
+
* it creates a second plan about the first plan and is guaranteed to feed back
|
|
249
|
+
* on itself. The main Agent can answer it from the session log/dashboard, so
|
|
250
|
+
* this gate bypasses the planner. It deliberately requires a question/status
|
|
251
|
+
* form so a real repair task such as "修复卡住的任务" still gets planned.
|
|
252
|
+
*/
|
|
253
|
+
const STATUS_PATTERNS = Object.freeze([
|
|
254
|
+
/完成(了)?(吗|么|没有|没|嘛)/,
|
|
255
|
+
/(全部|全都|都|已经).{0,6}完成/,
|
|
256
|
+
/(做|写|改|修)(完|好)(了)?(吗|么|没有|没|嘛)/,
|
|
257
|
+
/进度(如何|怎么样|多少|到哪|呢|吗)?/,
|
|
258
|
+
/卡住(了)?(吗|么|没有|没|嘛)?/,
|
|
259
|
+
/(还|剩)(有|下|余)?.{0,6}(多少|几|什么|哪些)/,
|
|
260
|
+
/是否.{0,8}(完成|做完|卡住|进行|通过)/,
|
|
261
|
+
/正确(吗|么|不)/,
|
|
262
|
+
/(显示|数字|统计|具体工作).{0,12}(正确|对不上|不一致|不对)/,
|
|
263
|
+
/(对不上|不一致)(吗|么|?|\?)/,
|
|
264
|
+
/(status|progress|done yet|completed|how many.*(left|remain)|what.*(left|remain))/i,
|
|
265
|
+
]);
|
|
266
|
+
export function isStatusQuestion(value) {
|
|
267
|
+
if (typeof value !== 'string')
|
|
268
|
+
return false;
|
|
269
|
+
const original = value.trim();
|
|
270
|
+
if (original.length === 0 || original.length > 120)
|
|
271
|
+
return false;
|
|
272
|
+
const looksLikeQuestion = /[??]/.test(original)
|
|
273
|
+
|| /(吗|呢|没有|是否|多少|几|如何|怎么样|到哪)/.test(original)
|
|
274
|
+
|| /^(查|看|告诉我|确认|核对).{0,8}(进度|状态|完成|卡住)/.test(original);
|
|
275
|
+
if (!looksLikeQuestion)
|
|
276
|
+
return false;
|
|
277
|
+
// A request to change/repair the progress UI is a real task, not a status
|
|
278
|
+
// question, even though it contains the word 进度.
|
|
279
|
+
if (/(改进|优化|设计|修复|实现|增加|调整|开发|重构|排查|诊断|改造|排版)/.test(original))
|
|
280
|
+
return false;
|
|
281
|
+
return STATUS_PATTERNS.some((pattern) => pattern.test(original));
|
|
282
|
+
}
|
|
283
|
+
function push(errors, path, message) {
|
|
284
|
+
errors.push(`${path}: ${message}`);
|
|
285
|
+
}
|
|
286
|
+
function readString(value, path, min, max, errors) {
|
|
287
|
+
if (typeof value !== 'string') {
|
|
288
|
+
push(errors, path, '必须是字符串');
|
|
289
|
+
return '';
|
|
290
|
+
}
|
|
291
|
+
const text = value.trim();
|
|
292
|
+
if (text.length < min) {
|
|
293
|
+
push(errors, path, `长度不能小于 ${min}`);
|
|
294
|
+
return text;
|
|
295
|
+
}
|
|
296
|
+
if (text.length > max) {
|
|
297
|
+
push(errors, path, `长度不能超过 ${max}`);
|
|
298
|
+
return text.slice(0, max);
|
|
299
|
+
}
|
|
300
|
+
return text;
|
|
301
|
+
}
|
|
302
|
+
function readStringArray(value, path, options, errors) {
|
|
303
|
+
const opts = { min: 0, max: 20, itemMax: 2000, ...options };
|
|
304
|
+
if (!Array.isArray(value)) {
|
|
305
|
+
push(errors, path, '必须是字符串数组');
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
if (value.length < opts.min)
|
|
309
|
+
push(errors, path, `至少需要 ${opts.min} 项`);
|
|
310
|
+
if (value.length > opts.max)
|
|
311
|
+
push(errors, path, `最多允许 ${opts.max} 项`);
|
|
312
|
+
const out = [];
|
|
313
|
+
value.slice(0, opts.max).forEach((item, index) => {
|
|
314
|
+
if (typeof item !== 'string') {
|
|
315
|
+
push(errors, `${path}[${index}]`, '必须是字符串');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const text = item.trim();
|
|
319
|
+
if (text.length === 0) {
|
|
320
|
+
push(errors, `${path}[${index}]`, '不能为空');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (text.length > opts.itemMax) {
|
|
324
|
+
push(errors, `${path}[${index}]`, `长度不能超过 ${opts.itemMax}`);
|
|
325
|
+
out.push(text.slice(0, opts.itemMax));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
out.push(text);
|
|
329
|
+
});
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
function readEnum(value, path, allowed, errors) {
|
|
333
|
+
if (!allowed.includes(value)) {
|
|
334
|
+
push(errors, path, `必须是 ${allowed.join(' / ')} 之一`);
|
|
335
|
+
return allowed[0];
|
|
336
|
+
}
|
|
337
|
+
return value;
|
|
338
|
+
}
|
|
339
|
+
function readTaskUnderstanding(value, errors) {
|
|
340
|
+
const source = isRecord(value) ? value : {};
|
|
341
|
+
if (!isRecord(value))
|
|
342
|
+
push(errors, 'taskUnderstanding', '必须是对象');
|
|
343
|
+
return {
|
|
344
|
+
originalRequest: readString(source.originalRequest, 'taskUnderstanding.originalRequest', 1, 8000, errors),
|
|
345
|
+
deliverable: readString(source.deliverable, 'taskUnderstanding.deliverable', 1, 2000, errors),
|
|
346
|
+
successCriteria: readStringArray(source.successCriteria, 'taskUnderstanding.successCriteria', { min: 1, max: 20, itemMax: 600 }, errors),
|
|
347
|
+
constraints: readStringArray(source.constraints, 'taskUnderstanding.constraints', { min: 0, max: 20, itemMax: 600 }, errors),
|
|
348
|
+
knownFacts: readStringArray(source.knownFacts, 'taskUnderstanding.knownFacts', { min: 0, max: 20, itemMax: 600 }, errors),
|
|
349
|
+
unknowns: readStringArray(source.unknowns, 'taskUnderstanding.unknowns', { min: 0, max: 20, itemMax: 600 }, errors),
|
|
350
|
+
assumptions: readStringArray(source.assumptions, 'taskUnderstanding.assumptions', { min: 0, max: 20, itemMax: 600 }, errors),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function readContradictions(value, errors) {
|
|
354
|
+
const source = isRecord(value) ? value : {};
|
|
355
|
+
if (!isRecord(value))
|
|
356
|
+
push(errors, 'contradictions', '必须是对象');
|
|
357
|
+
const primary = isRecord(source.primary) ? source.primary : {};
|
|
358
|
+
if (!isRecord(source.primary))
|
|
359
|
+
push(errors, 'contradictions.primary', '必须是对象');
|
|
360
|
+
return {
|
|
361
|
+
primary: {
|
|
362
|
+
statement: readString(primary.statement, 'contradictions.primary.statement', 1, 1200, errors),
|
|
363
|
+
evidence: readStringArray(primary.evidence, 'contradictions.primary.evidence', { min: 1, max: 10, itemMax: 800 }, errors),
|
|
364
|
+
whyPrimary: readString(primary.whyPrimary, 'contradictions.primary.whyPrimary', 1, 1200, errors),
|
|
365
|
+
},
|
|
366
|
+
secondary: readStringArray(source.secondary, 'contradictions.secondary', { min: 0, max: 10, itemMax: 800 }, errors),
|
|
367
|
+
transformationConditions: readStringArray(source.transformationConditions, 'contradictions.transformationConditions', { min: 0, max: 10, itemMax: 800 }, errors),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function readSubtasks(value, stageIndex, errors) {
|
|
371
|
+
const path = `stages[${stageIndex}].subtasks`;
|
|
372
|
+
if (!Array.isArray(value)) {
|
|
373
|
+
push(errors, path, '必须是数组');
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
if (value.length < 1)
|
|
377
|
+
push(errors, path, '每个阶段至少需要 1 个子任务');
|
|
378
|
+
if (value.length > MAX_SUBTASKS)
|
|
379
|
+
push(errors, path, `最多允许 ${MAX_SUBTASKS} 个子任务`);
|
|
380
|
+
return value.slice(0, MAX_SUBTASKS).map((item, index) => {
|
|
381
|
+
const source = isRecord(item) ? item : {};
|
|
382
|
+
const base = `${path}[${index}]`;
|
|
383
|
+
if (!isRecord(item))
|
|
384
|
+
push(errors, base, '必须是对象');
|
|
385
|
+
const department = typeof source.department === 'string'
|
|
386
|
+
? source.department
|
|
387
|
+
: '';
|
|
388
|
+
if (!DEPARTMENTS[department]) {
|
|
389
|
+
push(errors, `${base}.department`, `必须是 ${DEPARTMENT_IDS.join(' / ')} 之一`);
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
id: readString(source.id, `${base}.id`, 1, 16, errors),
|
|
393
|
+
title: readString(source.title, `${base}.title`, 1, 200, errors),
|
|
394
|
+
department,
|
|
395
|
+
role: readString(source.role, `${base}.role`, 1, 200, errors),
|
|
396
|
+
objective: readString(source.objective, `${base}.objective`, 1, 2000, errors),
|
|
397
|
+
method: readStringArray(source.method, `${base}.method`, { min: 1, max: 8, itemMax: 800 }, errors),
|
|
398
|
+
inputs: readStringArray(source.inputs, `${base}.inputs`, { min: 0, max: 12, itemMax: 800 }, errors),
|
|
399
|
+
outputs: readStringArray(source.outputs, `${base}.outputs`, { min: 1, max: 12, itemMax: 800 }, errors),
|
|
400
|
+
dependsOn: readStringArray(source.dependsOn, `${base}.dependsOn`, { min: 0, max: 12, itemMax: 16 }, errors),
|
|
401
|
+
acceptance: readStringArray(source.acceptance, `${base}.acceptance`, { min: 1, max: 8, itemMax: 800 }, errors),
|
|
402
|
+
risk: readString(source.risk, `${base}.risk`, 1, 1000, errors),
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
function readStages(value, errors) {
|
|
407
|
+
if (!Array.isArray(value)) {
|
|
408
|
+
push(errors, 'stages', '必须是数组');
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
if (value.length < 1 || value.length > MAX_STAGES) {
|
|
412
|
+
push(errors, 'stages', `数量必须为 1-${MAX_STAGES}`);
|
|
413
|
+
}
|
|
414
|
+
return value.slice(0, MAX_STAGES).map((item, index) => {
|
|
415
|
+
const source = isRecord(item) ? item : {};
|
|
416
|
+
const base = `stages[${index}]`;
|
|
417
|
+
if (!isRecord(item))
|
|
418
|
+
push(errors, base, '必须是对象');
|
|
419
|
+
return {
|
|
420
|
+
id: readString(source.id, `${base}.id`, 1, 16, errors),
|
|
421
|
+
name: readString(source.name, `${base}.name`, 1, 200, errors),
|
|
422
|
+
goal: readString(source.goal, `${base}.goal`, 1, 1200, errors),
|
|
423
|
+
rationale: readString(source.rationale, `${base}.rationale`, 1, 1200, errors),
|
|
424
|
+
checkpoint: readString(source.checkpoint, `${base}.checkpoint`, 1, 1200, errors),
|
|
425
|
+
subtasks: readSubtasks(source.subtasks, index, errors),
|
|
426
|
+
};
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
function readRisks(value, errors) {
|
|
430
|
+
if (!Array.isArray(value)) {
|
|
431
|
+
push(errors, 'risks', '必须是数组');
|
|
432
|
+
return [];
|
|
433
|
+
}
|
|
434
|
+
if (value.length > 8)
|
|
435
|
+
push(errors, 'risks', '最多允许 8 项');
|
|
436
|
+
return value.slice(0, 8).map((item, index) => {
|
|
437
|
+
const source = isRecord(item) ? item : {};
|
|
438
|
+
const base = `risks[${index}]`;
|
|
439
|
+
if (!isRecord(item))
|
|
440
|
+
push(errors, base, '必须是对象');
|
|
441
|
+
return {
|
|
442
|
+
risk: readString(source.risk, `${base}.risk`, 1, 800, errors),
|
|
443
|
+
likelihood: readEnum(source.likelihood, `${base}.likelihood`, LIKELIHOODS, errors),
|
|
444
|
+
impact: readString(source.impact, `${base}.impact`, 1, 800, errors),
|
|
445
|
+
mitigation: readString(source.mitigation, `${base}.mitigation`, 1, 800, errors),
|
|
446
|
+
trigger: readString(source.trigger, `${base}.trigger`, 1, 800, errors),
|
|
447
|
+
};
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
function readVerification(value, errors) {
|
|
451
|
+
if (!Array.isArray(value)) {
|
|
452
|
+
push(errors, 'verification', '必须是数组');
|
|
453
|
+
return [];
|
|
454
|
+
}
|
|
455
|
+
if (value.length < 1)
|
|
456
|
+
push(errors, 'verification', '至少需要 1 项');
|
|
457
|
+
if (value.length > 12)
|
|
458
|
+
push(errors, 'verification', '最多允许 12 项');
|
|
459
|
+
return value.slice(0, 12).map((item, index) => {
|
|
460
|
+
const source = isRecord(item) ? item : {};
|
|
461
|
+
const base = `verification[${index}]`;
|
|
462
|
+
if (!isRecord(item))
|
|
463
|
+
push(errors, base, '必须是对象');
|
|
464
|
+
return {
|
|
465
|
+
item: readString(source.item, `${base}.item`, 1, 600, errors),
|
|
466
|
+
method: readString(source.method, `${base}.method`, 1, 800, errors),
|
|
467
|
+
evidence: readString(source.evidence, `${base}.evidence`, 1, 800, errors),
|
|
468
|
+
};
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
function readPracticeLoop(value, errors) {
|
|
472
|
+
const source = isRecord(value) ? value : {};
|
|
473
|
+
if (!isRecord(value))
|
|
474
|
+
push(errors, 'practiceLoop', '必须是对象');
|
|
475
|
+
return {
|
|
476
|
+
plan: readString(source.plan, 'practiceLoop.plan', 1, 800, errors),
|
|
477
|
+
do: readString(source.do, 'practiceLoop.do', 1, 800, errors),
|
|
478
|
+
check: readString(source.check, 'practiceLoop.check', 1, 800, errors),
|
|
479
|
+
act: readString(source.act, 'practiceLoop.act', 1, 800, errors),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function checkPlanGraph(plan, errors) {
|
|
483
|
+
const stageIds = new Set();
|
|
484
|
+
const subtaskIds = new Set();
|
|
485
|
+
const order = new Map();
|
|
486
|
+
const dependencies = new Map();
|
|
487
|
+
let nextOrder = 0;
|
|
488
|
+
let total = 0;
|
|
489
|
+
for (const [stageIndex, stage] of plan.stages.entries()) {
|
|
490
|
+
if (!stage.id) {
|
|
491
|
+
push(errors, `stages[${stageIndex}].id`, '不能为空');
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (stageIds.has(stage.id)) {
|
|
495
|
+
push(errors, `stages[${stageIndex}].id`, `阶段 ID 重复:${stage.id}`);
|
|
496
|
+
}
|
|
497
|
+
stageIds.add(stage.id);
|
|
498
|
+
for (const [subtaskIndex, subtask] of stage.subtasks.entries()) {
|
|
499
|
+
const base = `stages[${stageIndex}].subtasks[${subtaskIndex}]`;
|
|
500
|
+
total += 1;
|
|
501
|
+
if (!subtask.id) {
|
|
502
|
+
push(errors, `${base}.id`, '不能为空');
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (subtaskIds.has(subtask.id)) {
|
|
506
|
+
push(errors, `${base}.id`, `子任务 ID 重复:${subtask.id}`);
|
|
507
|
+
}
|
|
508
|
+
subtaskIds.add(subtask.id);
|
|
509
|
+
order.set(subtask.id, nextOrder);
|
|
510
|
+
dependencies.set(subtask.id, subtask.dependsOn);
|
|
511
|
+
nextOrder += 1;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (total < 1)
|
|
515
|
+
push(errors, 'stages', '至少需要 1 个子任务');
|
|
516
|
+
if (total > MAX_SUBTASKS)
|
|
517
|
+
push(errors, 'stages', `子任务总数不能超过 ${MAX_SUBTASKS}`);
|
|
518
|
+
for (const [stageIndex, stage] of plan.stages.entries()) {
|
|
519
|
+
for (const [subtaskIndex, subtask] of stage.subtasks.entries()) {
|
|
520
|
+
const base = `stages[${stageIndex}].subtasks[${subtaskIndex}]`;
|
|
521
|
+
for (const dependency of subtask.dependsOn) {
|
|
522
|
+
if (!order.has(dependency)) {
|
|
523
|
+
push(errors, `${base}.dependsOn`, `未知子任务依赖:${dependency}`);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (order.get(dependency) >= order.get(subtask.id)) {
|
|
527
|
+
push(errors, `${base}.dependsOn`, `依赖必须位于更早阶段或更早位置:${dependency}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
const visiting = new Set();
|
|
533
|
+
const visited = new Set();
|
|
534
|
+
function visit(id) {
|
|
535
|
+
if (visited.has(id))
|
|
536
|
+
return;
|
|
537
|
+
if (visiting.has(id)) {
|
|
538
|
+
push(errors, 'stages', `依赖存在环:${id}`);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
visiting.add(id);
|
|
542
|
+
for (const dependency of dependencies.get(id) ?? [])
|
|
543
|
+
visit(dependency);
|
|
544
|
+
visiting.delete(id);
|
|
545
|
+
visited.add(id);
|
|
546
|
+
}
|
|
547
|
+
for (const id of dependencies.keys())
|
|
548
|
+
visit(id);
|
|
549
|
+
let serialized = '';
|
|
550
|
+
try {
|
|
551
|
+
serialized = JSON.stringify(plan);
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
push(errors, 'plan', '无法序列化为 JSON');
|
|
555
|
+
}
|
|
556
|
+
if (serialized.length > MAX_PLAN_JSON_CHARS) {
|
|
557
|
+
push(errors, 'plan', `计划 JSON 超过 ${MAX_PLAN_JSON_CHARS} 字符,请精简`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Validate an untrusted planner object. Never throws for malformed input.
|
|
562
|
+
*
|
|
563
|
+
* @param {unknown} raw
|
|
564
|
+
* @returns {{ok: true, plan: object} | {ok: false, errors: string[]}}
|
|
565
|
+
*/
|
|
566
|
+
export function validatePlan(raw) {
|
|
567
|
+
if (!isRecord(raw)) {
|
|
568
|
+
return { ok: false, errors: ['计划必须是 JSON 对象'] };
|
|
569
|
+
}
|
|
570
|
+
const errors = [];
|
|
571
|
+
if (raw.version !== PLAN_VERSION)
|
|
572
|
+
errors.push(`version: 必须为 ${PLAN_VERSION}`);
|
|
573
|
+
const plan = {
|
|
574
|
+
version: PLAN_VERSION,
|
|
575
|
+
title: readString(raw.title, 'title', 1, 200, errors),
|
|
576
|
+
taskUnderstanding: readTaskUnderstanding(raw.taskUnderstanding, errors),
|
|
577
|
+
contradictions: readContradictions(raw.contradictions, errors),
|
|
578
|
+
stages: readStages(raw.stages, errors),
|
|
579
|
+
risks: readRisks(raw.risks, errors),
|
|
580
|
+
verification: readVerification(raw.verification, errors),
|
|
581
|
+
practiceLoop: readPracticeLoop(raw.practiceLoop, errors),
|
|
582
|
+
};
|
|
583
|
+
checkPlanGraph(plan, errors);
|
|
584
|
+
if (errors.length > 0)
|
|
585
|
+
return { ok: false, errors };
|
|
586
|
+
return { ok: true, plan };
|
|
587
|
+
}
|
|
588
|
+
export function attachPlanMeta(plan, meta) {
|
|
589
|
+
return {
|
|
590
|
+
...plan,
|
|
591
|
+
planId: meta.planId,
|
|
592
|
+
sessionId: meta.sessionId,
|
|
593
|
+
createdTurn: meta.createdTurn,
|
|
594
|
+
createdAt: meta.createdAt,
|
|
595
|
+
sourceMessageIds: [...meta.sourceMessageIds],
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
export function flattenSubtasks(plan) {
|
|
599
|
+
const out = [];
|
|
600
|
+
for (const stage of plan.stages) {
|
|
601
|
+
for (const subtask of stage.subtasks) {
|
|
602
|
+
out.push({ stage, subtask });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return out;
|
|
606
|
+
}
|
|
607
|
+
export function extractJson(text) {
|
|
608
|
+
if (typeof text !== 'string' || text.trim() === '') {
|
|
609
|
+
throw new PlanError('规划器没有返回内容', 'EMPTY_OUTPUT');
|
|
610
|
+
}
|
|
611
|
+
let value = text.trim();
|
|
612
|
+
const fenced = value.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
613
|
+
if (fenced)
|
|
614
|
+
value = fenced[1].trim();
|
|
615
|
+
const first = value.indexOf('{');
|
|
616
|
+
const last = value.lastIndexOf('}');
|
|
617
|
+
if (first < 0 || last <= first) {
|
|
618
|
+
throw new PlanError('规划器返回内容中没有 JSON 对象', 'NO_JSON');
|
|
619
|
+
}
|
|
620
|
+
const candidate = value.slice(first, last + 1);
|
|
621
|
+
try {
|
|
622
|
+
return JSON.parse(candidate);
|
|
623
|
+
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
throw new PlanError(`规划器返回的 JSON 无法解析:${error instanceof Error ? error.message : String(error)}`, 'INVALID_JSON');
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
export function departmentLabel(id, language = 'zh') {
|
|
629
|
+
const entry = DEPARTMENTS[id];
|
|
630
|
+
if (!entry)
|
|
631
|
+
return id;
|
|
632
|
+
return language === 'en' ? entry.en : entry.zh;
|
|
633
|
+
}
|
|
634
|
+
export function buildPlannerPrompt(taskText, cwd, conversationContext = '') {
|
|
635
|
+
const departments = DEPARTMENT_IDS
|
|
636
|
+
.map((id) => { const d = DEPARTMENTS[id]; return `- ${id}(${d.zh} / ${d.en})`; })
|
|
637
|
+
.join('\n');
|
|
638
|
+
const task = truncate(taskText, 16000);
|
|
639
|
+
const directory = typeof cwd === 'string' && cwd ? cwd : '(未知)';
|
|
640
|
+
const context = truncate(conversationContext, 7000);
|
|
641
|
+
return [
|
|
642
|
+
'你是一个任务分解规划器。请用马克思主义哲学的科学方法分析下面的任务,并只输出一个 JSON 对象。',
|
|
643
|
+
'',
|
|
644
|
+
'必须遵守:',
|
|
645
|
+
'1. 实事求是:knownFacts 只能来自用户输入或可验证事实;不得编造文件、接口、数据或结论。',
|
|
646
|
+
'2. 抓主要矛盾:找出决定任务成败的核心矛盾,写清证据和“为什么它是主要矛盾”。',
|
|
647
|
+
'3. 具体问题具体分析:根据任务类型选择部门和方法,不要机械套模板。',
|
|
648
|
+
'4. 实践—认识—再实践:每个阶段设置检查点,用结果修正后续行动。',
|
|
649
|
+
'5. 质量互变:把大目标拆成可验收的量变阶段,设置阶段门槛。',
|
|
650
|
+
'6. 矛盾转化:说明什么条件下次要矛盾会上升为主要矛盾,以及如何应对。',
|
|
651
|
+
'',
|
|
652
|
+
'硬性约束:',
|
|
653
|
+
`- 阶段 1-${MAX_STAGES} 个;子任务总数 1-${MAX_SUBTASKS} 个;每个阶段至少 1 个子任务。`,
|
|
654
|
+
'- 按任务实际复杂度决定规模:简单任务 1-3 个子任务,复杂任务才展开;不得为了显得细致而堆叠子任务。',
|
|
655
|
+
'- 子任务 ID 使用 T1、T2…;阶段 ID 使用 S1、S2…;所有 ID 唯一。',
|
|
656
|
+
'- dependsOn 只能引用更早阶段或更早位置的子任务;不得形成环。',
|
|
657
|
+
'- dependsOn 只在真正需要前序产出时填写;能独立并行的子任务必须留空,不要为了形式制造依赖链。',
|
|
658
|
+
'- department 必须从下面的部门中选择一个。',
|
|
659
|
+
'- method、outputs、acceptance 必须是非空数组;inputs 可以为空,未知写“待确认”。',
|
|
660
|
+
'- unknowns 和 assumptions 必须如实列出;没有就写空数组,不能把假设写成事实。',
|
|
661
|
+
'- 子任务必须可独立执行、可验收;不要为了凑数拆无意义任务。',
|
|
662
|
+
'- 子任务由子 Agent 独立执行:它们没有派发权限、不能重启服务、不能安装或升级依赖。禁止把「调用 dsh_workplan_dispatch」「重启/停止 dsh web」「安装或升级插件」这类只有主 Agent 才能执行的动作写进子任务;确实需要时,写成“给出结论或建议,由主 Agent 执行”。',
|
|
663
|
+
'- 单个子任务应能在 10 分钟内完成;动手重的任务要拆成多个小步,避免整条计划被一个慢子任务拖住。',
|
|
664
|
+
'- 不要输出 Markdown 代码围栏、解释或前后缀;只输出一个 JSON 对象。',
|
|
665
|
+
'',
|
|
666
|
+
'可用部门:',
|
|
667
|
+
departments,
|
|
668
|
+
'',
|
|
669
|
+
`工作目录:${directory}`,
|
|
670
|
+
'',
|
|
671
|
+
'最近对话(只用于理解“它 / 这个 / B / 上一项”等指代,不是新的任务;不要把它当成额外需求):',
|
|
672
|
+
context || '(无可用对话背景)',
|
|
673
|
+
'',
|
|
674
|
+
'任务:',
|
|
675
|
+
task,
|
|
676
|
+
'',
|
|
677
|
+
'输出 JSON 结构(字段必须齐全,值要具体):',
|
|
678
|
+
'{',
|
|
679
|
+
' "version": 1,',
|
|
680
|
+
' "title": "短标题",',
|
|
681
|
+
' "taskUnderstanding": {',
|
|
682
|
+
' "originalRequest": "用户原任务复述",',
|
|
683
|
+
' "deliverable": "最终交付物",',
|
|
684
|
+
' "successCriteria": ["可验收标准"],',
|
|
685
|
+
' "constraints": ["约束"],',
|
|
686
|
+
' "knownFacts": ["仅来自用户输入或可验证事实"],',
|
|
687
|
+
' "unknowns": ["未验证项:如何验证"],',
|
|
688
|
+
' "assumptions": ["明确标注的假设"]',
|
|
689
|
+
' },',
|
|
690
|
+
' "contradictions": {',
|
|
691
|
+
' "primary": {',
|
|
692
|
+
' "statement": "主要矛盾",',
|
|
693
|
+
' "evidence": ["证据"],',
|
|
694
|
+
' "whyPrimary": "为什么它决定成败"',
|
|
695
|
+
' },',
|
|
696
|
+
' "secondary": ["次要矛盾"],',
|
|
697
|
+
' "transformationConditions": ["矛盾转化条件"]',
|
|
698
|
+
' },',
|
|
699
|
+
' "stages": [',
|
|
700
|
+
' {',
|
|
701
|
+
' "id": "S1",',
|
|
702
|
+
' "name": "阶段名",',
|
|
703
|
+
' "goal": "阶段目标",',
|
|
704
|
+
' "rationale": "为什么需要该阶段",',
|
|
705
|
+
' "checkpoint": "阶段检查点",',
|
|
706
|
+
' "subtasks": [',
|
|
707
|
+
' {',
|
|
708
|
+
' "id": "T1",',
|
|
709
|
+
' "title": "子任务",',
|
|
710
|
+
' "department": "engineering",',
|
|
711
|
+
' "role": "部门内角色",',
|
|
712
|
+
' "objective": "目标",',
|
|
713
|
+
' "method": ["具体方法/步骤"],',
|
|
714
|
+
' "inputs": ["输入;未知写待确认"],',
|
|
715
|
+
' "outputs": ["输出/交付物"],',
|
|
716
|
+
' "dependsOn": [],',
|
|
717
|
+
' "acceptance": ["可测试的验收标准"],',
|
|
718
|
+
' "risk": "主要风险"',
|
|
719
|
+
' }',
|
|
720
|
+
' ]',
|
|
721
|
+
' }',
|
|
722
|
+
' ],',
|
|
723
|
+
' "risks": [',
|
|
724
|
+
' {',
|
|
725
|
+
' "risk": "风险",',
|
|
726
|
+
' "likelihood": "low",',
|
|
727
|
+
' "impact": "影响",',
|
|
728
|
+
' "mitigation": "缓解",',
|
|
729
|
+
' "trigger": "触发信号"',
|
|
730
|
+
' }',
|
|
731
|
+
' ],',
|
|
732
|
+
' "verification": [',
|
|
733
|
+
' { "item": "验证项", "method": "方法", "evidence": "证据形式" }',
|
|
734
|
+
' ],',
|
|
735
|
+
' "practiceLoop": {',
|
|
736
|
+
' "plan": "计划",',
|
|
737
|
+
' "do": "执行",',
|
|
738
|
+
' "check": "检查",',
|
|
739
|
+
' "act": "修正"',
|
|
740
|
+
' }',
|
|
741
|
+
'}',
|
|
742
|
+
].join('\n');
|
|
743
|
+
}
|
|
744
|
+
export function makeUserMessage(text, source) {
|
|
745
|
+
return {
|
|
746
|
+
id: randomUUID(),
|
|
747
|
+
role: 'user',
|
|
748
|
+
content: [{ type: 'text', text }],
|
|
749
|
+
source,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
export function buildPlanSummary(plan) {
|
|
753
|
+
const total = flattenSubtasks(plan).length;
|
|
754
|
+
const primary = plan.contradictions.primary.statement;
|
|
755
|
+
return truncate(`【任务分解】${plan.title}|${plan.stages.length} 个阶段 / ${total} 个子任务|主要矛盾:${primary}`, 300);
|
|
756
|
+
}
|
|
757
|
+
export function buildPlanMessage(plan) {
|
|
758
|
+
const summary = buildPlanSummary(plan);
|
|
759
|
+
const payload = {
|
|
760
|
+
kind: 'workplan',
|
|
761
|
+
version: PLAN_VERSION,
|
|
762
|
+
planId: plan.planId,
|
|
763
|
+
sessionId: plan.sessionId,
|
|
764
|
+
createdTurn: plan.createdTurn,
|
|
765
|
+
createdAt: plan.createdAt,
|
|
766
|
+
plan,
|
|
767
|
+
};
|
|
768
|
+
const text = `${summary}\n\n${PLAN_MARKER_BEGIN}\n${JSON.stringify(payload)}\n${PLAN_MARKER_END}`;
|
|
769
|
+
return makeUserMessage(text, { kind: 'workplan', form: 'notice', summary });
|
|
770
|
+
}
|
|
771
|
+
export function buildDegradedMessage(options) {
|
|
772
|
+
const error = truncate(options.error ?? '未知错误', 600);
|
|
773
|
+
const summary = truncate(`【自动分解降级】${error}`, 300);
|
|
774
|
+
const payload = {
|
|
775
|
+
kind: 'workplan',
|
|
776
|
+
version: PLAN_VERSION,
|
|
777
|
+
planId: options.planId,
|
|
778
|
+
sessionId: options.sessionId,
|
|
779
|
+
createdTurn: options.createdTurn,
|
|
780
|
+
createdAt: options.createdAt,
|
|
781
|
+
degraded: true,
|
|
782
|
+
error,
|
|
783
|
+
title: '自动分解降级',
|
|
784
|
+
taskUnderstanding: {
|
|
785
|
+
originalRequest: truncate(options.taskText ?? '', 2000),
|
|
786
|
+
deliverable: '未能自动生成计划',
|
|
787
|
+
successCriteria: [],
|
|
788
|
+
constraints: [],
|
|
789
|
+
knownFacts: [],
|
|
790
|
+
unknowns: ['任务分解失败,需要用户确认是否重试'],
|
|
791
|
+
assumptions: [],
|
|
792
|
+
},
|
|
793
|
+
};
|
|
794
|
+
const text = [
|
|
795
|
+
summary,
|
|
796
|
+
'',
|
|
797
|
+
'自动任务分解失败,部门 Agent 未启动。',
|
|
798
|
+
'请先向用户说明失败原因,并询问是否重试;在用户确认前不要开始执行任务。',
|
|
799
|
+
'',
|
|
800
|
+
PLAN_MARKER_BEGIN,
|
|
801
|
+
JSON.stringify(payload),
|
|
802
|
+
PLAN_MARKER_END,
|
|
803
|
+
].join('\n');
|
|
804
|
+
return makeUserMessage(text, { kind: 'workplan', form: 'notice', summary });
|
|
805
|
+
}
|
|
806
|
+
export function buildResultSummary(result) {
|
|
807
|
+
const stats = result.stats ?? { done: 0, total: 0, blocked: 0 };
|
|
808
|
+
return truncate(`【部门执行结果】完成 ${stats.done}/${stats.total},受阻 ${stats.blocked},请主 Agent 核对并整合`, 300);
|
|
809
|
+
}
|
|
810
|
+
export function buildResultMessage(result) {
|
|
811
|
+
const summary = buildResultSummary(result);
|
|
812
|
+
const payload = {
|
|
813
|
+
kind: 'workplan-result',
|
|
814
|
+
version: PLAN_VERSION,
|
|
815
|
+
planId: result.planId,
|
|
816
|
+
sessionId: result.sessionId,
|
|
817
|
+
dispatchedAt: result.dispatchedAt,
|
|
818
|
+
statuses: result.statuses,
|
|
819
|
+
errors: result.errors ?? [],
|
|
820
|
+
stats: result.stats,
|
|
821
|
+
/*
|
|
822
|
+
* The round's final phase has to travel with the payload. Without it a
|
|
823
|
+
* budget pause could not be replayed after a page refresh: `@rebuild`
|
|
824
|
+
* guessed `executing` from the statuses, the card said "正在做" while
|
|
825
|
+
* nothing was running, and the pause looked like a stall again.
|
|
826
|
+
*/
|
|
827
|
+
phase: typeof result.phase === 'string' ? result.phase : undefined,
|
|
828
|
+
// Carried so a page refresh can still tell "resume budget exhausted" from
|
|
829
|
+
// "never resumed", instead of promising a retry that will be refused.
|
|
830
|
+
resumeCount: Number(result.resumeCount ?? 0),
|
|
831
|
+
resumeAvailable: result.resumeAvailable === true,
|
|
832
|
+
};
|
|
833
|
+
const blocked = Number(result.stats?.blocked ?? 0);
|
|
834
|
+
const paused = Number(result.stats?.pending ?? 0) > 0 && result.phase === 'paused';
|
|
835
|
+
const lines = [
|
|
836
|
+
summary,
|
|
837
|
+
'',
|
|
838
|
+
'各部门子任务结果如下,请主 Agent:',
|
|
839
|
+
'1. 核对每个完成项的证据与验收标准;',
|
|
840
|
+
'2. 处理受阻项,必要时向用户确认;',
|
|
841
|
+
'3. 给出最终答复,不要声称未验证的完成。',
|
|
842
|
+
// A blocked plan used to look identical to a finished one. State the exact
|
|
843
|
+
// recovery move so the model retries the failed work instead of rebuilding
|
|
844
|
+
// the whole plan or stopping to ask the user for a repeated request.
|
|
845
|
+
...(blocked > 0
|
|
846
|
+
? [result.resumeAvailable === false
|
|
847
|
+
? `4. 本计划已用尽自动续跑额度(${Number(result.resumeCount ?? 0)}/${MAX_PLAN_RESUMES}),受阻项不会再自动重跑:请主 Agent 直接接手完成,不要重复派发。`
|
|
848
|
+
: `4. 受阻项可再次调用 dsh_workplan_dispatch 从受阻处续跑(已完成的不会重跑,本计划剩余续跑额度 ${MAX_PLAN_RESUMES - Number(result.resumeCount ?? 0)} 次)。`]
|
|
849
|
+
: paused
|
|
850
|
+
? [`4. 本轮执行预算用尽,剩余 ${Number(result.stats?.pending ?? 0)} 项未完成(不是失败、未计入续跑额度):再次调用 dsh_workplan_dispatch 即可从剩下的步骤继续,已完成的不重跑。`]
|
|
851
|
+
: []),
|
|
852
|
+
'',
|
|
853
|
+
RESULT_MARKER_BEGIN,
|
|
854
|
+
JSON.stringify(payload),
|
|
855
|
+
RESULT_MARKER_END,
|
|
856
|
+
];
|
|
857
|
+
return makeUserMessage(lines.join('\n'), {
|
|
858
|
+
kind: 'workplan-result',
|
|
859
|
+
form: 'notice',
|
|
860
|
+
summary,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
function extractBetween(text, begin, end) {
|
|
864
|
+
if (typeof text !== 'string')
|
|
865
|
+
return null;
|
|
866
|
+
const start = text.indexOf(begin);
|
|
867
|
+
if (start < 0)
|
|
868
|
+
return null;
|
|
869
|
+
const finish = text.indexOf(end, start + begin.length);
|
|
870
|
+
if (finish < 0)
|
|
871
|
+
return null;
|
|
872
|
+
return text.slice(start + begin.length, finish).trim();
|
|
873
|
+
}
|
|
874
|
+
export function parseWorkplanMessage(message) {
|
|
875
|
+
const body = extractBetween(messageText(message), PLAN_MARKER_BEGIN, PLAN_MARKER_END);
|
|
876
|
+
if (!body)
|
|
877
|
+
return null;
|
|
878
|
+
try {
|
|
879
|
+
const payload = JSON.parse(body);
|
|
880
|
+
if (!isRecord(payload) || payload.kind !== 'workplan')
|
|
881
|
+
return null;
|
|
882
|
+
return payload;
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
export function parseResultMessage(message) {
|
|
889
|
+
const body = extractBetween(messageText(message), RESULT_MARKER_BEGIN, RESULT_MARKER_END);
|
|
890
|
+
if (!body)
|
|
891
|
+
return null;
|
|
892
|
+
try {
|
|
893
|
+
const payload = JSON.parse(body);
|
|
894
|
+
if (!isRecord(payload) || payload.kind !== 'workplan-result')
|
|
895
|
+
return null;
|
|
896
|
+
return payload;
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
export function buildCancelMessage(planId, reason = '用户取消') {
|
|
903
|
+
const summary = `【任务已取消】${truncate(String(reason || '用户取消'), 120)}`;
|
|
904
|
+
const payload = {
|
|
905
|
+
kind: 'workplan-cancel',
|
|
906
|
+
version: PLAN_VERSION,
|
|
907
|
+
planId,
|
|
908
|
+
reason: String(reason || '用户取消'),
|
|
909
|
+
cancelledAt: Date.now(),
|
|
910
|
+
};
|
|
911
|
+
const lines = [
|
|
912
|
+
summary,
|
|
913
|
+
'',
|
|
914
|
+
'这次任务分解已取消。取消只停止后续执行,不会把未完成项标成完成;已完成项保留原记录。',
|
|
915
|
+
'',
|
|
916
|
+
CANCEL_MARKER_BEGIN,
|
|
917
|
+
JSON.stringify(payload),
|
|
918
|
+
CANCEL_MARKER_END,
|
|
919
|
+
];
|
|
920
|
+
return makeUserMessage(lines.join('\n'), {
|
|
921
|
+
kind: 'workplan-cancel',
|
|
922
|
+
form: 'notice',
|
|
923
|
+
summary,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
export function parseCancelMessage(message) {
|
|
927
|
+
const body = extractBetween(messageText(message), CANCEL_MARKER_BEGIN, CANCEL_MARKER_END);
|
|
928
|
+
if (!body)
|
|
929
|
+
return null;
|
|
930
|
+
try {
|
|
931
|
+
const payload = JSON.parse(body);
|
|
932
|
+
if (!isRecord(payload) || payload.kind !== 'workplan-cancel')
|
|
933
|
+
return null;
|
|
934
|
+
return payload;
|
|
935
|
+
}
|
|
936
|
+
catch {
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
/*
|
|
941
|
+
* Replay a durable result message into statuses. Dashboard reconstruction used
|
|
942
|
+
* to read only the plan message, so after a page refresh every finished child
|
|
943
|
+
* looked pending again (0 done / 0 blocked / 12 pending). The result message is
|
|
944
|
+
* the only durable record of child outcomes, so it must be folded back too.
|
|
945
|
+
*/
|
|
946
|
+
export function applyResultStatuses(statuses, result) {
|
|
947
|
+
if (!Array.isArray(statuses) || !result || !Array.isArray(result.statuses))
|
|
948
|
+
return statuses;
|
|
949
|
+
let next = statuses;
|
|
950
|
+
for (const entry of result.statuses) {
|
|
951
|
+
if (!isRecord(entry) || typeof entry.subtaskId !== 'string')
|
|
952
|
+
continue;
|
|
953
|
+
if (!STATUSES.includes(entry.status))
|
|
954
|
+
continue;
|
|
955
|
+
if (!next.some((item) => item.subtaskId === entry.subtaskId))
|
|
956
|
+
continue;
|
|
957
|
+
try {
|
|
958
|
+
next = applyStatus(next, entry.subtaskId, {
|
|
959
|
+
status: entry.status,
|
|
960
|
+
progress: typeof entry.progress === 'number' ? entry.progress : undefined,
|
|
961
|
+
childId: entry.childId ?? null,
|
|
962
|
+
startedAt: entry.startedAt ?? null,
|
|
963
|
+
finishedAt: entry.finishedAt ?? null,
|
|
964
|
+
summary: typeof entry.summary === 'string' ? entry.summary : undefined,
|
|
965
|
+
error: entry.error ?? null,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
catch {
|
|
969
|
+
// Ignore a single corrupt historical status; the rest still replay.
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return next;
|
|
973
|
+
}
|
|
974
|
+
export function createStatuses(plan) {
|
|
975
|
+
return flattenSubtasks(plan).map(({ stage, subtask }) => ({
|
|
976
|
+
subtaskId: subtask.id,
|
|
977
|
+
stageId: stage.id,
|
|
978
|
+
title: subtask.title,
|
|
979
|
+
department: subtask.department,
|
|
980
|
+
role: subtask.role,
|
|
981
|
+
status: 'pending',
|
|
982
|
+
progress: 0,
|
|
983
|
+
childId: null,
|
|
984
|
+
startedAt: null,
|
|
985
|
+
finishedAt: null,
|
|
986
|
+
summary: '',
|
|
987
|
+
error: null,
|
|
988
|
+
}));
|
|
989
|
+
}
|
|
990
|
+
export function applyStatus(statuses, subtaskId, patch) {
|
|
991
|
+
const index = statuses.findIndex((item) => item.subtaskId === subtaskId);
|
|
992
|
+
if (index < 0)
|
|
993
|
+
throw new PlanError(`未知子任务:${subtaskId}`, 'UNKNOWN_SUBTASK');
|
|
994
|
+
const current = statuses[index];
|
|
995
|
+
const next = { ...current };
|
|
996
|
+
if (patch.status !== undefined) {
|
|
997
|
+
if (!STATUSES.includes(patch.status)) {
|
|
998
|
+
throw new PlanError(`非法状态:${String(patch.status)}`, 'INVALID_STATUS');
|
|
999
|
+
}
|
|
1000
|
+
next.status = patch.status;
|
|
1001
|
+
}
|
|
1002
|
+
if (patch.progress !== undefined) {
|
|
1003
|
+
if (!Number.isInteger(patch.progress) || patch.progress < 0 || patch.progress > 100) {
|
|
1004
|
+
throw new PlanError('progress 必须是 0-100 的整数', 'INVALID_PROGRESS');
|
|
1005
|
+
}
|
|
1006
|
+
next.progress = patch.progress;
|
|
1007
|
+
}
|
|
1008
|
+
if (patch.childId !== undefined)
|
|
1009
|
+
next.childId = patch.childId;
|
|
1010
|
+
if (patch.startedAt !== undefined)
|
|
1011
|
+
next.startedAt = patch.startedAt;
|
|
1012
|
+
if (patch.finishedAt !== undefined)
|
|
1013
|
+
next.finishedAt = patch.finishedAt;
|
|
1014
|
+
if (patch.summary !== undefined)
|
|
1015
|
+
next.summary = truncate(String(patch.summary), 3000);
|
|
1016
|
+
if (patch.error !== undefined)
|
|
1017
|
+
next.error = patch.error === null ? null : truncate(String(patch.error), 2000);
|
|
1018
|
+
if (patch.status === 'done') {
|
|
1019
|
+
next.progress = 100;
|
|
1020
|
+
if (next.finishedAt === null)
|
|
1021
|
+
next.finishedAt = Date.now();
|
|
1022
|
+
}
|
|
1023
|
+
if (patch.status === 'pending') {
|
|
1024
|
+
/*
|
|
1025
|
+
* A reset means "back to the initial state", so every companion field of
|
|
1026
|
+
* the previous attempt goes too.
|
|
1027
|
+
*
|
|
1028
|
+
* Leaving `summary` (and, unless the caller asked for one, `error`) behind
|
|
1029
|
+
* made a re-queued subtask read as `pending` while still carrying the
|
|
1030
|
+
* verdict of the attempt that just failed. Explicit values in the same
|
|
1031
|
+
* patch still win, so a caller can attach a reason such as “预算用尽”.
|
|
1032
|
+
*/
|
|
1033
|
+
next.progress = 0;
|
|
1034
|
+
next.startedAt = null;
|
|
1035
|
+
next.finishedAt = null;
|
|
1036
|
+
next.childId = null;
|
|
1037
|
+
if (patch.summary === undefined)
|
|
1038
|
+
next.summary = '';
|
|
1039
|
+
if (patch.error === undefined)
|
|
1040
|
+
next.error = null;
|
|
1041
|
+
}
|
|
1042
|
+
if (patch.status === 'in_progress' && next.startedAt === null) {
|
|
1043
|
+
next.startedAt = Date.now();
|
|
1044
|
+
}
|
|
1045
|
+
if (patch.status === 'blocked' && next.finishedAt === null) {
|
|
1046
|
+
next.finishedAt = Date.now();
|
|
1047
|
+
}
|
|
1048
|
+
const copy = statuses.slice();
|
|
1049
|
+
copy[index] = next;
|
|
1050
|
+
return copy;
|
|
1051
|
+
}
|
|
1052
|
+
export function isSubtaskStatus(value) {
|
|
1053
|
+
return typeof value === 'string' && STATUSES.includes(value);
|
|
1054
|
+
}
|
|
1055
|
+
/** Clamp anything to an integer in `[0, 100]`; non-finite input counts as 0. */
|
|
1056
|
+
export function clampProgress(value) {
|
|
1057
|
+
const numeric = Number(value);
|
|
1058
|
+
if (!Number.isFinite(numeric))
|
|
1059
|
+
return 0;
|
|
1060
|
+
return Math.min(100, Math.max(0, Math.round(numeric)));
|
|
1061
|
+
}
|
|
1062
|
+
function numberOrNull(value) {
|
|
1063
|
+
if (value === null || value === undefined)
|
|
1064
|
+
return null;
|
|
1065
|
+
const numeric = Number(value);
|
|
1066
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
1067
|
+
}
|
|
1068
|
+
function stringOr(value, fallback) {
|
|
1069
|
+
return typeof value === 'string' ? value : fallback;
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Structurally normalise one entry without touching the clock.
|
|
1073
|
+
*
|
|
1074
|
+
* Rules mirror the write path in `applyStatus`:
|
|
1075
|
+
* - an unknown status becomes `pending` (otherwise the four counters sum to
|
|
1076
|
+
* less than `total`, which is how a corrupt entry used to hide itself);
|
|
1077
|
+
* - `pending` carries no progress and no run bookkeeping;
|
|
1078
|
+
* - `done` is progress 100 even if a stale entry stored something else.
|
|
1079
|
+
*
|
|
1080
|
+
* Timestamps are only type-coerced, never filled in: filling them here would
|
|
1081
|
+
* change the payload on every poll just because time passed.
|
|
1082
|
+
*/
|
|
1083
|
+
export function normaliseStatusEntry(entry) {
|
|
1084
|
+
const source = entry ?? {};
|
|
1085
|
+
const status = isSubtaskStatus(source.status) ? source.status : 'pending';
|
|
1086
|
+
const progress = status === 'done' ? 100 : status === 'pending' ? 0 : clampProgress(source.progress);
|
|
1087
|
+
return {
|
|
1088
|
+
...source,
|
|
1089
|
+
subtaskId: stringOr(source.subtaskId, ''),
|
|
1090
|
+
stageId: stringOr(source.stageId, ''),
|
|
1091
|
+
title: stringOr(source.title, ''),
|
|
1092
|
+
department: stringOr(source.department, ''),
|
|
1093
|
+
role: stringOr(source.role, ''),
|
|
1094
|
+
status,
|
|
1095
|
+
progress,
|
|
1096
|
+
childId: status === 'pending' ? null : (source.childId ?? null),
|
|
1097
|
+
startedAt: status === 'pending' ? null : numberOrNull(source.startedAt),
|
|
1098
|
+
finishedAt: status === 'pending' ? null : numberOrNull(source.finishedAt),
|
|
1099
|
+
summary: stringOr(source.summary, ''),
|
|
1100
|
+
error: typeof source.error === 'string' ? source.error : null,
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Normalise a whole list.
|
|
1105
|
+
*
|
|
1106
|
+
* Entries without an identity (`subtaskId`) are dropped rather than counted: an
|
|
1107
|
+
* entry that cannot be attributed to a subtask would inflate `total` and make
|
|
1108
|
+
* every ratio on the dashboard lie.
|
|
1109
|
+
*/
|
|
1110
|
+
export function normaliseStatuses(statuses) {
|
|
1111
|
+
const list = Array.isArray(statuses) ? statuses : [];
|
|
1112
|
+
const result = [];
|
|
1113
|
+
for (const entry of list) {
|
|
1114
|
+
if (entry === null || entry === undefined || typeof entry !== 'object')
|
|
1115
|
+
continue;
|
|
1116
|
+
const normalised = normaliseStatusEntry(entry);
|
|
1117
|
+
if (normalised.subtaskId.trim() === '')
|
|
1118
|
+
continue;
|
|
1119
|
+
result.push(normalised);
|
|
1120
|
+
}
|
|
1121
|
+
return result;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Count in one pass.
|
|
1125
|
+
*
|
|
1126
|
+
* Overall progress weights by status instead of trusting the stored number:
|
|
1127
|
+
* `done` counts as 100, `in_progress` / `blocked` as their own (clamped)
|
|
1128
|
+
* progress, `pending` as 0. A subtask that was rewound to `pending` can
|
|
1129
|
+
* therefore never drag the plan's percentage up to where the cards say it is
|
|
1130
|
+
* finished — that was the “100% 但 11/12” contradiction users saw.
|
|
1131
|
+
*/
|
|
1132
|
+
export function deriveAggregates(statuses) {
|
|
1133
|
+
const departments = new Map();
|
|
1134
|
+
let done = 0;
|
|
1135
|
+
let blocked = 0;
|
|
1136
|
+
let inProgress = 0;
|
|
1137
|
+
let pending = 0;
|
|
1138
|
+
let progressSum = 0;
|
|
1139
|
+
for (const item of statuses) {
|
|
1140
|
+
if (item.status === 'done') {
|
|
1141
|
+
done += 1;
|
|
1142
|
+
progressSum += 100;
|
|
1143
|
+
}
|
|
1144
|
+
else if (item.status === 'blocked') {
|
|
1145
|
+
blocked += 1;
|
|
1146
|
+
progressSum += clampProgress(item.progress);
|
|
1147
|
+
}
|
|
1148
|
+
else if (item.status === 'in_progress') {
|
|
1149
|
+
inProgress += 1;
|
|
1150
|
+
progressSum += clampProgress(item.progress);
|
|
1151
|
+
}
|
|
1152
|
+
else {
|
|
1153
|
+
pending += 1;
|
|
1154
|
+
}
|
|
1155
|
+
const department = departments.get(item.department) ?? {
|
|
1156
|
+
id: item.department,
|
|
1157
|
+
total: 0,
|
|
1158
|
+
done: 0,
|
|
1159
|
+
blocked: 0,
|
|
1160
|
+
inProgress: 0,
|
|
1161
|
+
};
|
|
1162
|
+
department.total += 1;
|
|
1163
|
+
if (item.status === 'done')
|
|
1164
|
+
department.done += 1;
|
|
1165
|
+
else if (item.status === 'blocked')
|
|
1166
|
+
department.blocked += 1;
|
|
1167
|
+
else if (item.status === 'in_progress')
|
|
1168
|
+
department.inProgress += 1;
|
|
1169
|
+
departments.set(item.department, department);
|
|
1170
|
+
}
|
|
1171
|
+
const total = statuses.length;
|
|
1172
|
+
return {
|
|
1173
|
+
stats: {
|
|
1174
|
+
total,
|
|
1175
|
+
done,
|
|
1176
|
+
blocked,
|
|
1177
|
+
inProgress,
|
|
1178
|
+
pending,
|
|
1179
|
+
progress: total === 0 ? 0 : Math.round(progressSum / total),
|
|
1180
|
+
},
|
|
1181
|
+
departments: [...departments.values()],
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* The one entry point used when a plan is serialised.
|
|
1186
|
+
*
|
|
1187
|
+
* Input is read once, normalised once and aggregated once; the three returned
|
|
1188
|
+
* values share one array instance, so `stats`, `departments` and `statuses` in
|
|
1189
|
+
* a single payload cannot disagree.
|
|
1190
|
+
*/
|
|
1191
|
+
export function derivePlanSnapshot(raw) {
|
|
1192
|
+
const statuses = normaliseStatuses(raw);
|
|
1193
|
+
const { stats, departments } = deriveAggregates(statuses);
|
|
1194
|
+
return { statuses, stats, departments };
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Compatibility alias for callers that only need the counts.
|
|
1198
|
+
*
|
|
1199
|
+
* @deprecated Prefer `derivePlanSnapshot(statuses).stats` so the caller counts
|
|
1200
|
+
* the exact array it is about to ship.
|
|
1201
|
+
*/
|
|
1202
|
+
export function computeStats(statuses) {
|
|
1203
|
+
return derivePlanSnapshot(statuses).stats;
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Invariant checker for tests and CI; production paths never call it because
|
|
1207
|
+
* pure derivation is already self-consistent.
|
|
1208
|
+
*
|
|
1209
|
+
* Returns an empty array when every invariant holds. Each message names the
|
|
1210
|
+
* field and the observed value so it can be pasted straight into a report.
|
|
1211
|
+
*/
|
|
1212
|
+
export function aggregateInvariantViolations(snapshot) {
|
|
1213
|
+
const violations = [];
|
|
1214
|
+
const { statuses, stats, departments } = snapshot;
|
|
1215
|
+
if (stats.total !== statuses.length) {
|
|
1216
|
+
violations.push(`I1 stats.total=${stats.total} 与 statuses.length=${statuses.length} 不一致`);
|
|
1217
|
+
}
|
|
1218
|
+
const counted = stats.done + stats.blocked + stats.inProgress + stats.pending;
|
|
1219
|
+
if (counted !== stats.total) {
|
|
1220
|
+
violations.push(`I2 四个状态计数之和=${counted} 与 total=${stats.total} 不一致`);
|
|
1221
|
+
}
|
|
1222
|
+
for (const key of ['total', 'done', 'blocked', 'inProgress', 'pending']) {
|
|
1223
|
+
const value = stats[key];
|
|
1224
|
+
if (!Number.isInteger(value) || value < 0)
|
|
1225
|
+
violations.push(`I3 stats.${key}=${String(value)} 不是非负整数`);
|
|
1226
|
+
}
|
|
1227
|
+
if (!Number.isInteger(stats.progress) || stats.progress < 0 || stats.progress > 100) {
|
|
1228
|
+
violations.push(`I4 stats.progress=${String(stats.progress)} 不在 0..100`);
|
|
1229
|
+
}
|
|
1230
|
+
const seen = new Set();
|
|
1231
|
+
for (const item of statuses) {
|
|
1232
|
+
if (item.subtaskId === '')
|
|
1233
|
+
violations.push('I5 存在 subtaskId 为空的条目');
|
|
1234
|
+
if (seen.has(item.subtaskId))
|
|
1235
|
+
violations.push(`I5 subtaskId 重复:${item.subtaskId}`);
|
|
1236
|
+
seen.add(item.subtaskId);
|
|
1237
|
+
if (!Number.isInteger(item.progress) || item.progress < 0 || item.progress > 100) {
|
|
1238
|
+
violations.push(`I4 ${item.subtaskId}.progress=${String(item.progress)} 不在 0..100`);
|
|
1239
|
+
}
|
|
1240
|
+
if (item.status === 'pending') {
|
|
1241
|
+
if (item.progress !== 0)
|
|
1242
|
+
violations.push(`I6 ${item.subtaskId} 是 pending 但 progress=${item.progress}`);
|
|
1243
|
+
if (item.childId !== null)
|
|
1244
|
+
violations.push(`I6 ${item.subtaskId} 是 pending 但仍有 childId`);
|
|
1245
|
+
if (item.startedAt !== null)
|
|
1246
|
+
violations.push(`I6 ${item.subtaskId} 是 pending 但仍有 startedAt`);
|
|
1247
|
+
if (item.finishedAt !== null)
|
|
1248
|
+
violations.push(`I6 ${item.subtaskId} 是 pending 但仍有 finishedAt`);
|
|
1249
|
+
}
|
|
1250
|
+
if (item.status === 'done' && item.progress !== 100) {
|
|
1251
|
+
violations.push(`I6 ${item.subtaskId} 是 done 但 progress=${item.progress}`);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
const sums = { total: 0, done: 0, blocked: 0, inProgress: 0 };
|
|
1255
|
+
for (const department of departments) {
|
|
1256
|
+
sums.total += department.total;
|
|
1257
|
+
sums.done += department.done;
|
|
1258
|
+
sums.blocked += department.blocked;
|
|
1259
|
+
sums.inProgress += department.inProgress;
|
|
1260
|
+
const pendingHere = statuses.filter((item) => item.department === department.id && item.status === 'pending').length;
|
|
1261
|
+
if (department.total !== department.done + department.blocked + department.inProgress + pendingHere) {
|
|
1262
|
+
violations.push(`I7 部门 ${department.id} 的计数之和与 total 不一致`);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
if (sums.total !== stats.total)
|
|
1266
|
+
violations.push(`I7 各部门 total 之和=${sums.total} 与 stats.total=${stats.total} 不一致`);
|
|
1267
|
+
if (sums.done !== stats.done)
|
|
1268
|
+
violations.push(`I7 各部门 done 之和=${sums.done} 与 stats.done=${stats.done} 不一致`);
|
|
1269
|
+
if (sums.blocked !== stats.blocked)
|
|
1270
|
+
violations.push(`I7 各部门 blocked 之和=${sums.blocked} 与 stats.blocked=${stats.blocked} 不一致`);
|
|
1271
|
+
if (sums.inProgress !== stats.inProgress) {
|
|
1272
|
+
violations.push(`I7 各部门 inProgress 之和=${sums.inProgress} 与 stats.inProgress=${stats.inProgress} 不一致`);
|
|
1273
|
+
}
|
|
1274
|
+
return violations;
|
|
1275
|
+
}
|
|
1276
|
+
export function planPhase(statuses, resultSeen = false) {
|
|
1277
|
+
if (statuses.length === 0)
|
|
1278
|
+
return 'pending';
|
|
1279
|
+
if (statuses.every((item) => item.status === 'done'))
|
|
1280
|
+
return resultSeen ? 'done' : 'integrating';
|
|
1281
|
+
if (statuses.some((item) => item.status === 'blocked'))
|
|
1282
|
+
return 'blocked';
|
|
1283
|
+
if (statuses.some((item) => item.status === 'in_progress' || item.status === 'done'))
|
|
1284
|
+
return 'executing';
|
|
1285
|
+
return 'pending';
|
|
1286
|
+
}
|
|
1287
|
+
export function nextReadyInStage(plan, statuses, stageId) {
|
|
1288
|
+
const byId = new Map(statuses.map((item) => [item.subtaskId, item]));
|
|
1289
|
+
const stage = plan.stages.find((item) => item.id === stageId);
|
|
1290
|
+
if (!stage)
|
|
1291
|
+
return [];
|
|
1292
|
+
return stage.subtasks.filter((subtask) => {
|
|
1293
|
+
const status = byId.get(subtask.id);
|
|
1294
|
+
if (!status || status.status !== 'pending')
|
|
1295
|
+
return false;
|
|
1296
|
+
return subtask.dependsOn.every((dependency) => byId.get(dependency)?.status === 'done');
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
/*
|
|
1300
|
+
* Readiness across the whole plan, not per stage. A stage is only a grouping
|
|
1301
|
+
* label; making every later stage wait for every earlier stage lets one slow
|
|
1302
|
+
* child turn an entire plan red. Only real dependsOn edges gate a subtask.
|
|
1303
|
+
*/
|
|
1304
|
+
export function readySubtasks(plan, statuses) {
|
|
1305
|
+
const byId = new Map(statuses.map((item) => [item.subtaskId, item]));
|
|
1306
|
+
return flattenSubtasks(plan)
|
|
1307
|
+
.map((entry) => entry.subtask)
|
|
1308
|
+
.filter((subtask) => {
|
|
1309
|
+
const status = byId.get(subtask.id);
|
|
1310
|
+
if (!status || status.status !== 'pending')
|
|
1311
|
+
return false;
|
|
1312
|
+
return subtask.dependsOn.every((dependency) => byId.get(dependency)?.status === 'done');
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
export function markDependentsBlocked(plan, statuses, blockedId) {
|
|
1316
|
+
let next = statuses.slice();
|
|
1317
|
+
let changed = true;
|
|
1318
|
+
const blocked = new Set([blockedId]);
|
|
1319
|
+
while (changed) {
|
|
1320
|
+
changed = false;
|
|
1321
|
+
for (const { subtask } of flattenSubtasks(plan)) {
|
|
1322
|
+
if (blocked.has(subtask.id))
|
|
1323
|
+
continue;
|
|
1324
|
+
const status = next.find((item) => item.subtaskId === subtask.id);
|
|
1325
|
+
if (!status || status.status !== 'pending')
|
|
1326
|
+
continue;
|
|
1327
|
+
if (subtask.dependsOn.some((dependency) => blocked.has(dependency))) {
|
|
1328
|
+
next = applyStatus(next, subtask.id, {
|
|
1329
|
+
status: 'blocked',
|
|
1330
|
+
error: `上游子任务受阻:${subtask.dependsOn.filter((id) => blocked.has(id)).join('、')}`,
|
|
1331
|
+
});
|
|
1332
|
+
blocked.add(subtask.id);
|
|
1333
|
+
changed = true;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
return next;
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* How many times one plan may be re-dispatched after it went blocked.
|
|
1341
|
+
*
|
|
1342
|
+
* Re-dispatch used to be a silent no-op: every subtask was already `blocked`,
|
|
1343
|
+
* `readySubtasks()` returned nothing, and the dispatcher returned
|
|
1344
|
+
* `{ ok: true, phase: 'blocked' }` without running a single child. A model that
|
|
1345
|
+
* followed the prompt ("核对结果后再继续") could not tell "nothing ran" apart
|
|
1346
|
+
* from "everything already ran", so the plan stayed dead and the only recovery
|
|
1347
|
+
* was asking the user to repeat the request — which built a fresh plan and hit
|
|
1348
|
+
* the same wall.
|
|
1349
|
+
*/
|
|
1350
|
+
export const MAX_PLAN_RESUMES = 4;
|
|
1351
|
+
/**
|
|
1352
|
+
* Reset every failed or half-run subtask to `pending` so it can run again.
|
|
1353
|
+
*
|
|
1354
|
+
* `done` results are preserved: the retry is a resume, not a restart. That
|
|
1355
|
+
* matters because a child may take minutes of real work, and re-running it
|
|
1356
|
+
* would also re-run its side effects.
|
|
1357
|
+
*
|
|
1358
|
+
* @param statuses - the current status list.
|
|
1359
|
+
* @returns a new status list with blocked/in_progress entries cleared.
|
|
1360
|
+
*/
|
|
1361
|
+
export function resetBlockedStatuses(statuses) {
|
|
1362
|
+
if (!Array.isArray(statuses))
|
|
1363
|
+
return [];
|
|
1364
|
+
let next = statuses.slice();
|
|
1365
|
+
for (const item of statuses) {
|
|
1366
|
+
if (item?.status !== 'blocked' && item?.status !== 'in_progress')
|
|
1367
|
+
continue;
|
|
1368
|
+
next = applyStatus(next, item.subtaskId, {
|
|
1369
|
+
status: 'pending',
|
|
1370
|
+
progress: 0,
|
|
1371
|
+
error: null,
|
|
1372
|
+
summary: '',
|
|
1373
|
+
startedAt: null,
|
|
1374
|
+
finishedAt: null,
|
|
1375
|
+
childId: null,
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
return next;
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Decide whether re-dispatching this plan should resume the failed subtasks.
|
|
1382
|
+
*
|
|
1383
|
+
* Cancellation is never resumed automatically: a user cancel and a turn abort
|
|
1384
|
+
* both end in `cancelled`, and silently restarting work the user stopped is
|
|
1385
|
+
* worse than asking again. Both `blocked` and `paused` plans resume.
|
|
1386
|
+
*
|
|
1387
|
+
* @param record - the live plan record.
|
|
1388
|
+
* @param maxResumes - resume budget, defaults to {@link MAX_PLAN_RESUMES}.
|
|
1389
|
+
* @returns the decision plus a model-readable reason when refused.
|
|
1390
|
+
*/
|
|
1391
|
+
export function canResumePlan(record, maxResumes = MAX_PLAN_RESUMES) {
|
|
1392
|
+
const statuses = Array.isArray(record?.statuses) ? record.statuses : [];
|
|
1393
|
+
/*
|
|
1394
|
+
* A `paused` plan is resumable too.
|
|
1395
|
+
*
|
|
1396
|
+
* Running out of the round budget leaves the unfinished subtasks `pending` on
|
|
1397
|
+
* purpose, so before this they were invisible here: the payload said
|
|
1398
|
+
* `resumeAvailable: false` and the card told the user the automatic resume
|
|
1399
|
+
* budget was spent, even though re-dispatching simply continues the work.
|
|
1400
|
+
* Pending work after a pause counts as resumable, and a pause is not charged
|
|
1401
|
+
* against the resume budget (it is a time slice, not a repair).
|
|
1402
|
+
*/
|
|
1403
|
+
const paused = record?.phase === 'paused';
|
|
1404
|
+
const failed = statuses.filter((item) => {
|
|
1405
|
+
if (item?.status === 'blocked' || item?.status === 'in_progress')
|
|
1406
|
+
return true;
|
|
1407
|
+
return paused && item?.status === 'pending';
|
|
1408
|
+
});
|
|
1409
|
+
const used = Number(record?.resumeCount ?? 0);
|
|
1410
|
+
if (failed.length === 0) {
|
|
1411
|
+
return { resume: false, reason: '没有需要续跑的子任务', failed: 0, used, remaining: maxResumes - used };
|
|
1412
|
+
}
|
|
1413
|
+
if (record?.phase !== 'blocked' && !paused) {
|
|
1414
|
+
return {
|
|
1415
|
+
resume: false,
|
|
1416
|
+
reason: `计划当前阶段是 ${String(record?.phase ?? 'unknown')},不能续跑`,
|
|
1417
|
+
failed: failed.length,
|
|
1418
|
+
used,
|
|
1419
|
+
remaining: maxResumes - used,
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
if (!paused && used >= maxResumes) {
|
|
1423
|
+
return {
|
|
1424
|
+
resume: false,
|
|
1425
|
+
reason: `续跑次数已用尽(${used}/${maxResumes}),受阻子任务需要主 Agent 直接接手完成`,
|
|
1426
|
+
failed: failed.length,
|
|
1427
|
+
used,
|
|
1428
|
+
remaining: 0,
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
return { resume: true, reason: '', failed: failed.length, used, remaining: maxResumes - used };
|
|
1432
|
+
}
|
|
1433
|
+
export function findSubtask(plan, subtaskId) {
|
|
1434
|
+
for (const { stage, subtask } of flattenSubtasks(plan)) {
|
|
1435
|
+
if (subtask.id === subtaskId)
|
|
1436
|
+
return { stage, subtask };
|
|
1437
|
+
}
|
|
1438
|
+
return null;
|
|
1439
|
+
}
|
|
1440
|
+
export function buildChildPrompt({ plan, subtask, stage, dependencyResults, cwd, conversationContext = '' }) {
|
|
1441
|
+
/*
|
|
1442
|
+
* Every field below is read defensively.
|
|
1443
|
+
*
|
|
1444
|
+
* A plan that was reconstructed, resumed, or written by an older version can
|
|
1445
|
+
* legitimately lack `taskUnderstanding` / `contradictions`. Dereferencing them
|
|
1446
|
+
* directly threw inside prompt building, so each child failed instantly and
|
|
1447
|
+
* the whole plan was blocked — a stuck plan caused by missing metadata rather
|
|
1448
|
+
* than by the work being impossible.
|
|
1449
|
+
*/
|
|
1450
|
+
const results = Array.isArray(dependencyResults) ? dependencyResults : [];
|
|
1451
|
+
const dependencies = results.length === 0
|
|
1452
|
+
? '无'
|
|
1453
|
+
: results
|
|
1454
|
+
.map((item) => `- ${item.id} ${item.title}:${item.status}${item.summary ? `|${item.summary}` : ''}`)
|
|
1455
|
+
.join('\n');
|
|
1456
|
+
const background = truncate(conversationContext, 5000);
|
|
1457
|
+
const understanding = plan?.taskUnderstanding ?? {};
|
|
1458
|
+
const contradiction = plan?.contradictions?.primary?.statement ?? '(未记录)';
|
|
1459
|
+
const stageName = stage?.name ?? '(未命名阶段)';
|
|
1460
|
+
const stageGoal = stage?.goal ?? '(未记录目标)';
|
|
1461
|
+
return [
|
|
1462
|
+
`你是「${departmentLabel(subtask.department)}」部门的执行 Agent,只负责下面这一个子任务。`,
|
|
1463
|
+
'',
|
|
1464
|
+
`全局任务:${understanding.originalRequest ?? '(未记录)'}`,
|
|
1465
|
+
`最终交付物:${understanding.deliverable ?? '(未记录)'}`,
|
|
1466
|
+
`主要矛盾:${contradiction}`,
|
|
1467
|
+
`当前阶段:${stageName}(${stageGoal})`,
|
|
1468
|
+
`工作目录:${cwd || '(未知)'}`,
|
|
1469
|
+
'',
|
|
1470
|
+
'最近对话背景(用于理解“它 / 这个 / B / 上一项”等指代;不是新的任务):',
|
|
1471
|
+
background || '(无可用对话背景)',
|
|
1472
|
+
'',
|
|
1473
|
+
`你的子任务:${subtask.id} ${subtask.title}`,
|
|
1474
|
+
`角色:${subtask.role}`,
|
|
1475
|
+
`目标:${subtask.objective}`,
|
|
1476
|
+
`方法:${(subtask.method ?? []).join(';') || '(未指定)'}`,
|
|
1477
|
+
`输入:${Array.isArray(subtask.inputs) && subtask.inputs.length ? subtask.inputs.join(';') : '无明确输入'}`,
|
|
1478
|
+
`输出:${(subtask.outputs ?? []).join(';') || '(未指定)'}`,
|
|
1479
|
+
`验收标准:${(subtask.acceptance ?? []).join(';') || '(未指定)'}`,
|
|
1480
|
+
`风险:${subtask.risk}`,
|
|
1481
|
+
'',
|
|
1482
|
+
'依赖子任务的已知结果:',
|
|
1483
|
+
dependencies,
|
|
1484
|
+
'',
|
|
1485
|
+
'执行要求:',
|
|
1486
|
+
'- 只完成本子任务,不擅自扩大范围;不要派生新的 Agent。',
|
|
1487
|
+
'- 你没有派发权限,也不能重启或安装任何服务;遇到这类步骤,直接写清“需要主 Agent 执行什么”,不要等待或空转。',
|
|
1488
|
+
'- 不得编造事实、文件、命令结果或证据;无法确认时明确写“未验证”。',
|
|
1489
|
+
'- 可以读取工作目录、运行必要命令、修改与任务相关的文件。',
|
|
1490
|
+
'- 优先使用给定上下文和本地工具;只有任务明确需要最新外部信息时才联网搜索。',
|
|
1491
|
+
'- 控制工具调用次数,证据足够就立即汇报;不要反复搜索或等待长时间任务。',
|
|
1492
|
+
'- 如果确实无法完成,第一行输出 `BLOCKED: 原因`,然后简要说明。',
|
|
1493
|
+
'- 完成后用简洁中文汇报:做了什么、证据/文件、验收结果、遗留问题。',
|
|
1494
|
+
].join('\n');
|
|
1495
|
+
}
|