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/lib/runtime.js ADDED
@@ -0,0 +1,1755 @@
1
+ /**
2
+ * Host runtime for dsh-workplan.
3
+ *
4
+ * The runtime owns three things:
5
+ * - the `agent/pre-step` listener that plans and dispatches before the model's
6
+ * first step;
7
+ * - the in-memory dashboard cache, reconstructed from the durable session log
8
+ * when missing;
9
+ * - the two model-facing tools that update or re-dispatch a plan.
10
+ *
11
+ * Every failure is converted into an explicit blocked/degraded record. Nothing
12
+ * in this file is allowed to escape as an unhandled rejection from a listener.
13
+ */
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';
16
+ import { createProgressWatchdog, describeError } from './watchdog.js';
17
+ export const PLANNER_TIMEOUT_MS = 120000;
18
+ /**
19
+ * Absolute ceiling for one child execution.
20
+ *
21
+ * 300 000 ms used to be the *only* limit, and because it was purely a wall
22
+ * clock it killed healthy work: a child that needed one more minute was
23
+ * recorded as blocked, `markDependentsBlocked` blocked every dependent, and the
24
+ * plan became terminal at the first slow subtask. The ceiling is now a
25
+ * settlement guarantee, while the no-progress window below is what normally
26
+ * ends an attempt.
27
+ */
28
+ export const CHILD_TIMEOUT_MS = 900000;
29
+ /** No-progress window: the child's own session log stopped growing. */
30
+ export const CHILD_IDLE_TIMEOUT_MS = 300000;
31
+ /*
32
+ * Floors exist to reject zero, negative and absurdly small values, not to
33
+ * overrule an operator. The defaults above are what production uses; a floor of
34
+ * 1s keeps diagnostics and tests able to exercise the timers without waiting
35
+ * minutes.
36
+ */
37
+ export const MIN_CHILD_TIMEOUT_MS = 1000;
38
+ export const MAX_CHILD_TIMEOUT_MS = 1800000;
39
+ export const MIN_CHILD_IDLE_MS = 1000;
40
+ export const MAX_CHILD_IDLE_MS = 900000;
41
+ export const CHILD_POLL_MS = 2000;
42
+ export const TOTAL_TIMEOUT_MS = 1200000;
43
+ export const DISPOSE_TIMEOUT_MS = 5000;
44
+ export const STALE_PENDING_MS = 10 * 60 * 1000;
45
+ export const MAX_HISTORY_EVENTS = 2000;
46
+ export const MAX_LIVE_SESSIONS = 20;
47
+ function abortError() {
48
+ return new PlanError('执行已取消', 'ABORTED');
49
+ }
50
+ export function isRootSession(session) {
51
+ const header = session?.header;
52
+ if (!header)
53
+ return true;
54
+ if (header.parentSession)
55
+ return false;
56
+ if (header.origin === 'subagent')
57
+ return false;
58
+ if (typeof header.delegationDepth === 'number' && header.delegationDepth > 0)
59
+ return false;
60
+ return true;
61
+ }
62
+ export function humanMessages(messages) {
63
+ if (!Array.isArray(messages))
64
+ return [];
65
+ return messages.filter((message) => {
66
+ if (!message || message.role !== 'user')
67
+ return false;
68
+ const kind = message.source?.kind;
69
+ if (kind === undefined)
70
+ return true;
71
+ return kind === 'user';
72
+ });
73
+ }
74
+ function taskTextFromMessages(messages) {
75
+ const texts = [];
76
+ for (const message of humanMessages(messages)) {
77
+ const text = messageText(message).trim();
78
+ if (text)
79
+ texts.push(text);
80
+ }
81
+ return truncate(texts.join('\n\n'), 16000);
82
+ }
83
+ /*
84
+ * Children start with zero parent context. Without recent turns they cannot
85
+ * resolve "B", "it", "the previous item", so they search blindly and time out.
86
+ * Build a bounded plain-text window from the session log (newest last) and pass
87
+ * it to both the planner and every child. Plugin-generated plan/result rows are
88
+ * excluded to avoid feeding the plan back into itself.
89
+ */
90
+ function recentConversation(session, maxChars = 7000) {
91
+ if (!session || typeof session.eventAt !== 'function')
92
+ return '';
93
+ const parts = [];
94
+ let seq = typeof session.seq === 'number' ? session.seq - 1 : -1;
95
+ let scanned = 0;
96
+ let total = 0;
97
+ while (seq >= 0 && scanned < 800 && total < maxChars) {
98
+ const event = session.eventAt(seq);
99
+ seq -= 1;
100
+ scanned += 1;
101
+ if (!event)
102
+ continue;
103
+ if (event.type !== 'user/message' && event.type !== 'assistant/message')
104
+ continue;
105
+ const message = event.data?.message ?? event.data;
106
+ const kind = message?.source?.kind;
107
+ if (kind === 'workplan' || kind === 'workplan-result')
108
+ continue;
109
+ const text = messageText(message).trim();
110
+ if (!text)
111
+ continue;
112
+ const line = `${event.type === 'user/message' ? '用户' : '助手'}:${truncate(text, 1200)}`;
113
+ parts.unshift(line);
114
+ total += line.length + 2;
115
+ }
116
+ return truncate(parts.join('\n\n'), maxChars);
117
+ }
118
+ export function isPlanModeActive(ctx, session) {
119
+ try {
120
+ const service = ctx.get?.('sessionProjections');
121
+ const snapshot = service?.snapshot?.(session, ['plan']);
122
+ const state = snapshot?.values?.plan;
123
+ return state?.active === true || state?.pending === true;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ function enterDecision(downstream, extraMessages) {
130
+ if (!downstream || downstream.kind !== 'enter')
131
+ return downstream;
132
+ const decision = { kind: 'enter', messages: [...downstream.messages, ...extraMessages] };
133
+ if (downstream.startsRequestSeries === true)
134
+ decision.startsRequestSeries = true;
135
+ return decision;
136
+ }
137
+ function pushRecordError(record, error) {
138
+ // String(error) on a plain object produced "[object Object]" in the durable
139
+ // error list, which made the failure undiagnosable after the fact.
140
+ const text = truncate(describeError(error), 1000);
141
+ record.errors = [...(record.errors ?? []), text].slice(-20);
142
+ }
143
+ async function collectStream(stream) {
144
+ let text = '';
145
+ let textDeltaSeen = false;
146
+ let finished = false;
147
+ let finishKind = null;
148
+ for await (const chunk of stream) {
149
+ if (!chunk || typeof chunk !== 'object')
150
+ continue;
151
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
152
+ text += chunk.text;
153
+ textDeltaSeen = true;
154
+ }
155
+ else if (chunk.type === 'block-end' && chunk.block?.type === 'text' && !textDeltaSeen) {
156
+ text += chunk.block.text ?? '';
157
+ }
158
+ else if (chunk.type === 'finish') {
159
+ finished = true;
160
+ finishKind = chunk.reason?.kind ?? 'unknown';
161
+ if (finishKind === 'error' || finishKind === 'aborted') {
162
+ const failure = chunk.reason?.failure;
163
+ throw new PlanError(`模型调用结束:${finishKind}${failure?.code ? ` ${failure.code}` : ''}${failure?.message ? `:${failure.message}` : ''}`, finishKind === 'aborted' ? 'ABORTED' : 'PLANNER_LLM_ERROR');
164
+ }
165
+ if (finishKind !== 'stop') {
166
+ throw new PlanError(`规划器输出未正常结束:${finishKind}`, 'PLANNER_INCOMPLETE');
167
+ }
168
+ }
169
+ }
170
+ if (!finished)
171
+ throw new PlanError('规划器没有返回终止信号', 'PLANNER_INCOMPLETE');
172
+ if (!text.trim())
173
+ throw new PlanError('规划器返回空内容', 'EMPTY_OUTPUT');
174
+ return text;
175
+ }
176
+ async function runPlanner(ctx, options) {
177
+ const { taskText, cwd, sessionId, modelSelection, signal, conversationContext = '' } = options;
178
+ let lastErrors = [];
179
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
180
+ if (signal.aborted)
181
+ throw abortError();
182
+ const controller = new AbortController();
183
+ const onAbort = () => controller.abort(signal.reason ?? abortError());
184
+ signal.addEventListener('abort', onAbort, { once: true });
185
+ const timeout = setTimeout(() => {
186
+ controller.abort(new PlanError('规划器超时', 'PLANNER_TIMEOUT'));
187
+ }, PLANNER_TIMEOUT_MS);
188
+ try {
189
+ let prompt = buildPlannerPrompt(taskText, cwd, conversationContext);
190
+ if (lastErrors.length > 0) {
191
+ prompt += [
192
+ '',
193
+ '上一次输出未通过校验,请修复以下问题后重新输出完整 JSON:',
194
+ ...lastErrors.slice(0, 12).map((error) => `- ${error}`),
195
+ ].join('\n');
196
+ }
197
+ const stream = ctx.llm.stream({
198
+ provider: modelSelection.provider,
199
+ model: modelSelection.model,
200
+ reasoningEffort: modelSelection.reasoningEffort,
201
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
202
+ signal: controller.signal,
203
+ sessionId,
204
+ });
205
+ const text = await collectStream(stream);
206
+ const raw = extractJson(text);
207
+ const validated = validatePlan(raw);
208
+ if (validated.ok)
209
+ return validated.plan;
210
+ lastErrors = validated.errors ?? [];
211
+ }
212
+ catch (error) {
213
+ if (signal.aborted || (controller.signal.aborted && controller.signal.reason?.code === 'ABORTED')) {
214
+ throw abortError();
215
+ }
216
+ if (error instanceof PlanError && error.code === 'ABORTED')
217
+ throw error;
218
+ lastErrors = [error instanceof Error ? error.message : String(error)];
219
+ }
220
+ finally {
221
+ clearTimeout(timeout);
222
+ signal.removeEventListener('abort', onAbort);
223
+ }
224
+ }
225
+ throw new PlanError(`规划器两次输出均未通过校验:${lastErrors.slice(0, 5).join(';')}`, 'PLANNER_FAILED', lastErrors);
226
+ }
227
+ function createChildController(parentSignal) {
228
+ const controller = new AbortController();
229
+ const onAbort = () => controller.abort(parentSignal.reason ?? abortError());
230
+ if (parentSignal.aborted)
231
+ onAbort();
232
+ else
233
+ parentSignal.addEventListener('abort', onAbort, { once: true });
234
+ return {
235
+ controller,
236
+ disposeListener: () => parentSignal.removeEventListener('abort', onAbort),
237
+ };
238
+ }
239
+ function waitForActivation(activation, signal) {
240
+ const result = activation.result;
241
+ result.catch(() => { });
242
+ return new Promise((resolve, reject) => {
243
+ let settled = false;
244
+ const onAbort = () => {
245
+ if (settled)
246
+ return;
247
+ settled = true;
248
+ reject(signal.reason ?? abortError());
249
+ };
250
+ if (signal.aborted) {
251
+ onAbort();
252
+ return;
253
+ }
254
+ signal.addEventListener('abort', onAbort, { once: true });
255
+ result.then((value) => {
256
+ if (settled)
257
+ return;
258
+ settled = true;
259
+ signal.removeEventListener('abort', onAbort);
260
+ resolve(value);
261
+ }, (error) => {
262
+ if (settled)
263
+ return;
264
+ settled = true;
265
+ signal.removeEventListener('abort', onAbort);
266
+ reject(error);
267
+ });
268
+ });
269
+ }
270
+ function resultText(result) {
271
+ const text = messageText({ content: result?.output ?? [] }).trim();
272
+ return text;
273
+ }
274
+ async function disposeActivation(activation, record, subtaskId, guard) {
275
+ let timer = null;
276
+ try {
277
+ await Promise.race([
278
+ activation.dispose(),
279
+ new Promise((_, reject) => {
280
+ timer = setTimeout(() => reject(new Error('dispose timeout')), DISPOSE_TIMEOUT_MS);
281
+ timer.unref?.();
282
+ }),
283
+ ]);
284
+ }
285
+ catch (error) {
286
+ if (!guard?.timedOut) {
287
+ record.errors = [
288
+ ...record.errors,
289
+ `${subtaskId} dispose: ${truncate(String(error?.message ?? error), 300)}`,
290
+ ].slice(-20);
291
+ }
292
+ }
293
+ finally {
294
+ if (timer)
295
+ clearTimeout(timer);
296
+ }
297
+ }
298
+ function clamp(value, min, max, fallback) {
299
+ const numeric = Number(value);
300
+ if (!Number.isFinite(numeric) || numeric <= 0)
301
+ return fallback;
302
+ return Math.min(max, Math.max(min, Math.floor(numeric)));
303
+ }
304
+ /**
305
+ * Absolute child ceiling, clamped so an operator cannot configure a value that
306
+ * outlives the plan budget or a disabled timer.
307
+ *
308
+ * @param value - configured value, if any.
309
+ * @returns milliseconds within `[MIN_CHILD_TIMEOUT_MS, MAX_CHILD_TIMEOUT_MS]`.
310
+ */
311
+ export function normaliseChildTimeout(value) {
312
+ return clamp(value, MIN_CHILD_TIMEOUT_MS, MAX_CHILD_TIMEOUT_MS, CHILD_TIMEOUT_MS);
313
+ }
314
+ /**
315
+ * No-progress window, clamped to stay meaningful next to the ceiling.
316
+ *
317
+ * @param value - configured value, if any.
318
+ * @returns milliseconds within `[MIN_CHILD_IDLE_MS, MAX_CHILD_IDLE_MS]`.
319
+ */
320
+ export function normaliseIdleTimeout(value) {
321
+ return clamp(value, MIN_CHILD_IDLE_MS, MAX_CHILD_IDLE_MS, CHILD_IDLE_TIMEOUT_MS);
322
+ }
323
+ /*
324
+ * Every status write for a subtask goes through this guard. Once the
325
+ * subtask-level deadline has fired, the subtask is already recorded as blocked;
326
+ * a child that settles afterwards (for example an activation whose abort signal
327
+ * was ignored) must not be allowed to rewrite that verdict to done.
328
+ */
329
+ function writeSubtaskStatus(record, subtaskId, patch, guard) {
330
+ if (guard.timedOut)
331
+ return false;
332
+ record.statuses = applyStatus(record.statuses, subtaskId, patch);
333
+ record.updatedAt = Date.now();
334
+ return true;
335
+ }
336
+ function recordSubtaskError(record, subtaskId, message, guard) {
337
+ if (guard.timedOut)
338
+ return;
339
+ record.errors = [...record.errors, `${subtaskId}: ${truncate(message, 500)}`].slice(-20);
340
+ }
341
+ async function executeSubtask(ctx, record, stage, subtask, options) {
342
+ const { parentSignal, deadline, modelSelection, conversationContext = '', child, guard } = options;
343
+ let lastError = null;
344
+ try {
345
+ if (!writeSubtaskStatus(record, subtask.id, {
346
+ status: 'in_progress',
347
+ progress: 5,
348
+ startedAt: Date.now(),
349
+ }, guard))
350
+ return;
351
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
352
+ if (guard.timedOut)
353
+ return;
354
+ if (parentSignal.aborted)
355
+ throw abortError();
356
+ if (Date.now() >= deadline)
357
+ throw new PlanError('总执行超时', 'TOTAL_TIMEOUT');
358
+ let activation = null;
359
+ try {
360
+ if (child.controller.signal.aborted) {
361
+ throw child.controller.signal.reason ?? abortError();
362
+ }
363
+ activation = await ctx.subagents.startActivation({
364
+ provider: 'spawn',
365
+ label: `${subtask.department}:${subtask.id} ${subtask.title}`.slice(0, 120),
366
+ request: {
367
+ prompt: [{
368
+ type: 'text',
369
+ text: buildChildPrompt({
370
+ plan: record.plan,
371
+ subtask,
372
+ stage,
373
+ dependencyResults: record.statuses
374
+ .filter((item) => subtask.dependsOn.includes(item.subtaskId))
375
+ .map((item) => ({
376
+ id: item.subtaskId,
377
+ title: item.title,
378
+ status: item.status,
379
+ summary: item.summary,
380
+ })),
381
+ cwd: options.cwd,
382
+ conversationContext,
383
+ }),
384
+ }],
385
+ parent: options.agent,
386
+ cwd: typeof options.cwd === 'string' && options.cwd ? options.cwd : undefined,
387
+ agentOptions: modelSelection?.provider && modelSelection?.model
388
+ ? { provider: modelSelection.provider, model: modelSelection.model }
389
+ : undefined,
390
+ maxDepth: 1,
391
+ },
392
+ signal: child.controller.signal,
393
+ delivery: 'caller',
394
+ });
395
+ if (guard.timedOut)
396
+ return;
397
+ if (!activation || typeof activation.result?.then !== 'function') {
398
+ throw new PlanError('子 Agent 没有返回可等待的结果', 'NO_ACTIVATION');
399
+ }
400
+ writeSubtaskStatus(record, subtask.id, {
401
+ childId: activation.childId,
402
+ progress: 10,
403
+ }, guard);
404
+ const idleTimeoutMs = normaliseIdleTimeout(options.idleTimeoutMs);
405
+ const hardTimeoutMs = normaliseChildTimeout(options.childTimeoutMs);
406
+ const watchdog = createProgressWatchdog({
407
+ getSeq: () => {
408
+ try {
409
+ const childSession = ctx.sessions?.get?.(activation.childId);
410
+ const seq = childSession?.seq;
411
+ return typeof seq === 'number' ? seq : undefined;
412
+ }
413
+ catch {
414
+ return undefined;
415
+ }
416
+ },
417
+ idleMs: idleTimeoutMs,
418
+ hardMs: Math.max(1000, Math.min(hardTimeoutMs, deadline - Date.now())),
419
+ // Poll often enough to notice growth inside a short idle window, but
420
+ // never more than four times per window.
421
+ pollMs: Math.min(CHILD_POLL_MS, Math.max(20, Math.floor(idleTimeoutMs / 4))),
422
+ onProgress: ({ now }) => {
423
+ if (guard.timedOut)
424
+ return;
425
+ // Visible liveness: the dashboard can show that a slow subtask is
426
+ // still producing events instead of looking frozen.
427
+ record.heartbeatAt = now;
428
+ record.updatedAt = now;
429
+ },
430
+ makeError: (reason) => new PlanError(reason === 'stalled'
431
+ ? `子任务 ${subtask.id} 无进展超时(${idleTimeoutMs}ms 内子 Agent 没有产生任何新事件)`
432
+ : `子任务 ${subtask.id} 超时(${hardTimeoutMs}ms)`, reason === 'stalled' ? 'CHILD_STALLED' : 'CHILD_TIMEOUT', { subtaskId: subtask.id }),
433
+ });
434
+ let result;
435
+ try {
436
+ const waiter = waitForActivation(activation, child.controller.signal);
437
+ // The losing side of the race may reject after the watchdog fired;
438
+ // keep that rejection handled so Node never reports it as unhandled.
439
+ waiter.catch(() => { });
440
+ result = await Promise.race([waiter, watchdog.fired]);
441
+ }
442
+ finally {
443
+ watchdog.stop();
444
+ }
445
+ if (guard.timedOut)
446
+ return;
447
+ const output = resultText(result);
448
+ if (result.stopReason !== 'completed') {
449
+ throw new PlanError(`子 Agent 结束状态为 ${result.stopReason}${result.diagnostic ? `:${result.diagnostic}` : ''}`, 'CHILD_NOT_COMPLETED');
450
+ }
451
+ if (!output)
452
+ throw new PlanError('子 Agent 没有返回文本结果', 'EMPTY_CHILD_OUTPUT');
453
+ if (/^\s*BLOCKED\s*[::]/i.test(output)) {
454
+ writeSubtaskStatus(record, subtask.id, {
455
+ status: 'blocked',
456
+ progress: 100,
457
+ finishedAt: Date.now(),
458
+ summary: truncate(output, 3000),
459
+ error: truncate(output.replace(/^\s*BLOCKED\s*[::]\s*/i, ''), 2000),
460
+ }, guard);
461
+ recordSubtaskError(record, subtask.id, output, guard);
462
+ return;
463
+ }
464
+ writeSubtaskStatus(record, subtask.id, {
465
+ status: 'done',
466
+ progress: 100,
467
+ finishedAt: Date.now(),
468
+ summary: truncate(output, 3000),
469
+ error: null,
470
+ }, guard);
471
+ return;
472
+ }
473
+ catch (error) {
474
+ lastError = error;
475
+ if (guard.timedOut)
476
+ break;
477
+ const isAbort = parentSignal.aborted || child.controller.signal.aborted;
478
+ /*
479
+ * Two failure kinds, two different correct reactions.
480
+ *
481
+ * A stalled child is wedged — no event for the whole idle window — so
482
+ * one fresh attempt can succeed and is worth trying. A hard ceiling
483
+ * means the budget is gone, so retrying would only burn the plan's
484
+ * remaining time; it goes straight to blocked.
485
+ */
486
+ const stalled = error?.code === 'CHILD_STALLED';
487
+ if (stalled && attempt < 2 && !isAbort) {
488
+ record.errors = [
489
+ ...record.errors,
490
+ `${subtask.id}: 第 ${attempt} 次执行无进展,已停止并重试`,
491
+ ].slice(-20);
492
+ writeSubtaskStatus(record, subtask.id, { progress: 15 }, guard);
493
+ continue;
494
+ }
495
+ if (isAbort || attempt >= 2 || error?.code === 'CHILD_NOT_COMPLETED' && /aborted|refusal/i.test(String(error?.message ?? ''))) {
496
+ break;
497
+ }
498
+ writeSubtaskStatus(record, subtask.id, { progress: 15 }, guard);
499
+ }
500
+ finally {
501
+ if (activation) {
502
+ await disposeActivation(activation, record, subtask.id, guard);
503
+ activation = null;
504
+ }
505
+ }
506
+ }
507
+ const message = describeError(lastError ?? new Error('未知失败'));
508
+ writeSubtaskStatus(record, subtask.id, {
509
+ status: 'blocked',
510
+ progress: 100,
511
+ finishedAt: Date.now(),
512
+ error: truncate(message, 2000),
513
+ summary: truncate(`未完成:${message}`, 3000),
514
+ }, guard);
515
+ recordSubtaskError(record, subtask.id, message, guard);
516
+ }
517
+ catch (error) {
518
+ const message = describeError(error);
519
+ try {
520
+ writeSubtaskStatus(record, subtask.id, {
521
+ status: 'blocked',
522
+ progress: 100,
523
+ finishedAt: Date.now(),
524
+ error: truncate(message, 2000),
525
+ summary: truncate(`未完成:${message}`, 3000),
526
+ }, guard);
527
+ }
528
+ catch {
529
+ // The status list itself is unusable; the outer dispatcher records the error.
530
+ }
531
+ recordSubtaskError(record, subtask.id, message, guard);
532
+ }
533
+ }
534
+ /*
535
+ * Runs one subtask under a deadline that belongs to that subtask alone.
536
+ *
537
+ * The subtask timer is a full settlement guarantee, not just an abort hint: if
538
+ * the child activation ignores its abort signal, or never settles at all, the
539
+ * race below still resolves, that subtask is recorded as blocked, and the
540
+ * dispatcher keeps scheduling the rest of the DAG. This function never throws
541
+ * on a per-subtask timeout, so one slow child can no longer blanket-block the
542
+ * whole plan — only that subtask (and its real dependents) is affected.
543
+ */
544
+ async function runSubtask(ctx, record, stage, subtask, options) {
545
+ const childTimeoutMs = normaliseChildTimeout(options.childTimeoutMs);
546
+ const child = createChildController(options.parentSignal);
547
+ const guard = { timedOut: false };
548
+ let timer = null;
549
+ const deadlinePromise = new Promise((resolve) => {
550
+ timer = setTimeout(() => {
551
+ guard.timedOut = true;
552
+ // Reached only when the child ignored both its idle window and its
553
+ // abort signal; the message says so, otherwise the cause looks like a
554
+ // plain "too slow" and the next investigation goes down the wrong path.
555
+ const message = `子任务 ${subtask.id} 达到执行上限(${childTimeoutMs}ms),已强制结束`;
556
+ try {
557
+ record.statuses = applyStatus(record.statuses, subtask.id, {
558
+ status: 'blocked',
559
+ progress: 100,
560
+ finishedAt: Date.now(),
561
+ error: message,
562
+ summary: truncate(`未完成:${message}`, 3000),
563
+ });
564
+ record.errors = [...record.errors, `${subtask.id}: ${message}`].slice(-20);
565
+ record.updatedAt = Date.now();
566
+ }
567
+ catch (error) {
568
+ record.errors = [
569
+ ...record.errors,
570
+ `${subtask.id}: 超时状态写入失败:${truncate(String(error?.message ?? error), 500)}`,
571
+ ].slice(-20);
572
+ }
573
+ finally {
574
+ if (!child.controller.signal.aborted) {
575
+ child.controller.abort(new PlanError(message, 'CHILD_TIMEOUT', { subtaskId: subtask.id }));
576
+ }
577
+ child.disposeListener();
578
+ resolve();
579
+ }
580
+ }, childTimeoutMs);
581
+ timer.unref?.();
582
+ });
583
+ try {
584
+ await Promise.race([
585
+ executeSubtask(ctx, record, stage, subtask, {
586
+ ...options,
587
+ child,
588
+ guard,
589
+ }),
590
+ deadlinePromise,
591
+ ]);
592
+ }
593
+ finally {
594
+ if (timer) {
595
+ clearTimeout(timer);
596
+ timer = null;
597
+ }
598
+ child.disposeListener();
599
+ if (!child.controller.signal.aborted)
600
+ child.controller.abort();
601
+ }
602
+ }
603
+ async function runWithConcurrency(items, limit, worker) {
604
+ let cursor = 0;
605
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
606
+ while (true) {
607
+ const index = cursor;
608
+ cursor += 1;
609
+ if (index >= items.length)
610
+ return;
611
+ await worker(items[index], index);
612
+ }
613
+ });
614
+ await Promise.all(workers);
615
+ }
616
+ /**
617
+ * Build the durable result message for the round that just ended.
618
+ *
619
+ * Extracted from `dispatchTool` so the wiring itself is testable: a mutation
620
+ * test on 1.3.3 dropped `phase: record.phase` at the call site and all 62 tests
621
+ * still passed, because every test stopped short of the call site. Now the
622
+ * phase reaching the payload is pinned by a test of this function.
623
+ *
624
+ * @param record - the live plan record at the end of the round.
625
+ * @param sessionId - the owning session id.
626
+ * @param limits - resolved runtime limits (the resume budget comes from here).
627
+ * @returns a user message carrying the `workplan-result` payload.
628
+ */
629
+ export function buildRoundResult(record, sessionId, limits) {
630
+ const { statuses, stats } = derivePlanSnapshot(record.statuses);
631
+ return buildResultMessage({
632
+ planId: record.plan.planId,
633
+ sessionId,
634
+ dispatchedAt: record.dispatchedAt ?? Date.now(),
635
+ statuses,
636
+ errors: record.errors,
637
+ stats,
638
+ // The exact phase this round ended in, so a refresh can replay a pause
639
+ // instead of inventing `executing` for work that is not running.
640
+ phase: record.phase,
641
+ resumeCount: Number(record.resumeCount ?? 0),
642
+ resumeAvailable: canResumePlan(record, limits.maxResumes).resume,
643
+ });
644
+ }
645
+ export async function dispatchPlan(ctx, record, options) {
646
+ const { agent, signal, modelSelection, cwd, conversationContext = '' } = options;
647
+ const deadline = Date.now() + (options.totalTimeoutMs ?? TOTAL_TIMEOUT_MS);
648
+ /*
649
+ * Resume, do not restart.
650
+ *
651
+ * A blocked plan used to be terminal: every subtask was `blocked`, so
652
+ * `readySubtasks()` returned nothing and the dispatcher broke out having run
653
+ * zero children. Clearing only the failed attempts keeps finished work (and
654
+ * its side effects) while giving the rest a second chance, and the resume
655
+ * budget stops a subtask that always fails from looping forever.
656
+ */
657
+ const resume = canResumePlan(record, options.maxResumes ?? MAX_PLAN_RESUMES);
658
+ if (resume.resume) {
659
+ /*
660
+ * Continuing after a budget pause is not a repair, so it is not charged
661
+ * against the resume budget and it does not reset anything: the paused
662
+ * subtasks are already `pending`. Only a `blocked` plan pays a resume and
663
+ * needs its failed attempts cleared.
664
+ */
665
+ const repairing = record.phase === 'blocked';
666
+ if (repairing)
667
+ record.resumeCount = Number(record.resumeCount ?? 0) + 1;
668
+ record.statuses = resetBlockedStatuses(record.statuses);
669
+ // A resume produces a new durable outcome; without clearing this flag the
670
+ // second result message was never written and a page refresh replayed the
671
+ // stale blocked state.
672
+ record.resultWritten = false;
673
+ record.error = null;
674
+ if (repairing) {
675
+ record.errors = [
676
+ ...(record.errors ?? []),
677
+ `第 ${record.resumeCount} 次续跑:已重置 ${resume.failed} 个受阻子任务(已完成的不重跑)`,
678
+ ].slice(-20);
679
+ }
680
+ else {
681
+ record.errors = (record.errors ?? []).filter((line) => !/预算|续跑/.test(String(line))).slice(-20);
682
+ }
683
+ }
684
+ const parentSignal = signal;
685
+ const childController = new AbortController();
686
+ const onAbort = () => childController.abort(parentSignal.reason ?? abortError());
687
+ if (parentSignal.aborted)
688
+ onAbort();
689
+ else
690
+ parentSignal.addEventListener('abort', onAbort, { once: true });
691
+ const totalTimer = setTimeout(() => {
692
+ childController.abort(new PlanError('总执行超时', 'TOTAL_TIMEOUT'));
693
+ }, Math.max(1000, deadline - Date.now()));
694
+ totalTimer.unref?.();
695
+ record.dispatchedAt = Date.now();
696
+ record.phase = 'executing';
697
+ record.updatedAt = record.dispatchedAt;
698
+ try {
699
+ let guard = 0;
700
+ while (true) {
701
+ guard += 1;
702
+ if (guard > 200)
703
+ throw new PlanError('调度次数异常', 'DISPATCH_LOOP');
704
+ if (parentSignal.aborted)
705
+ throw abortError();
706
+ if (Date.now() >= deadline)
707
+ throw new PlanError('总执行超时', 'TOTAL_TIMEOUT');
708
+ const ready = readySubtasks(record.plan, record.statuses);
709
+ if (ready.length === 0) {
710
+ const pending = record.statuses.filter((item) => item.status === 'pending');
711
+ for (const item of pending) {
712
+ record.statuses = applyStatus(record.statuses, item.subtaskId, {
713
+ status: 'blocked',
714
+ error: '依赖未完成或上游受阻',
715
+ });
716
+ }
717
+ break;
718
+ }
719
+ await runWithConcurrency(ready, MAX_CONCURRENT, async (item) => {
720
+ // readySubtasks() returns subtask objects (they carry `id`, not
721
+ // `subtaskId`); using the status key here silently skipped every task
722
+ // and spun the dispatcher until DISPATCH_LOOP marked all blocked.
723
+ const entry = flattenSubtasks(record.plan)
724
+ .find((candidate) => candidate.subtask.id === item.id);
725
+ if (!entry)
726
+ return;
727
+ await runSubtask(ctx, record, entry.stage, entry.subtask, {
728
+ agent,
729
+ parentSignal: childController.signal,
730
+ deadline,
731
+ childTimeoutMs: options.childTimeoutMs,
732
+ idleTimeoutMs: options.idleTimeoutMs,
733
+ modelSelection,
734
+ cwd,
735
+ conversationContext,
736
+ });
737
+ });
738
+ const blocked = record.statuses
739
+ .filter((item) => item.status === 'blocked')
740
+ .map((item) => item.subtaskId);
741
+ for (const id of blocked) {
742
+ record.statuses = markDependentsBlocked(record.plan, record.statuses, id);
743
+ }
744
+ /*
745
+ * No stats refresh here any more: `serialisePlan` derives the counts from
746
+ * exactly the list it ships, so a cache can no longer lag behind the work.
747
+ */
748
+ record.updatedAt = Date.now();
749
+ }
750
+ record.phase = planPhase(record.statuses);
751
+ }
752
+ catch (error) {
753
+ const message = describeError(error);
754
+ pushRecordError(record, error);
755
+ const budgetExhausted = error?.code === 'TOTAL_TIMEOUT' && !parentSignal.aborted;
756
+ if (budgetExhausted) {
757
+ /*
758
+ * Running out of the round budget is not a task failure.
759
+ *
760
+ * This used to be indistinguishable from a real failure: the child that
761
+ * happened to be running was recorded as `blocked` with “总执行超时”, the
762
+ * cascade blocked everything downstream, and the dashboard showed
763
+ * “0/N 做好了 · N 卡住了” — the exact symptom users reported as a stall.
764
+ * Keep unfinished work `pending` so the next dispatch simply continues,
765
+ * and mark the plan `paused` so the card can say why.
766
+ */
767
+ /*
768
+ * Reset through `applyStatus`, not by hand.
769
+ *
770
+ * The previous hand-written spread kept `progress`, `startedAt` and
771
+ * `childId` of the aborted attempt, so a re-queued subtask shipped as
772
+ * `{status:'pending', progress:100, childId:'…'}`. The task list then said
773
+ * “还没开始” while the counters and the progress bar said 100 % — one of
774
+ * the contradictions reported against 1.4.2. `applyStatus` is the single
775
+ * definition of what `pending` means, so the reset goes through it; the
776
+ * explicit `error` stays as the reason the round stopped.
777
+ */
778
+ let reset = record.statuses;
779
+ for (const item of record.statuses) {
780
+ if (item.status === 'done')
781
+ continue;
782
+ reset = applyStatus(reset, item.subtaskId, {
783
+ status: 'pending',
784
+ error: '本轮预算用尽,等待续跑',
785
+ });
786
+ }
787
+ record.statuses = reset;
788
+ record.phase = 'paused';
789
+ const total = record.statuses.length;
790
+ const done = record.statuses.filter((item) => item.status === 'done').length;
791
+ const minutes = Math.max(1, Math.round((options.totalTimeoutMs ?? TOTAL_TIMEOUT_MS) / 60000));
792
+ record.error = `本轮执行预算(${minutes} 分钟)已到:已完成 ${done}/${total},其余 ${total - done} 项保留为未完成,再次派发本计划即可续跑(已完成的不重跑)`;
793
+ /*
794
+ * Drop the failure lines the aborted children wrote on their way out.
795
+ * They used to survive into the result payload as
796
+ * `["T4: 总执行超时", "总执行超时", …]`, so the model read a failure
797
+ * report for subtasks that were explicitly *not* failures.
798
+ */
799
+ record.errors = [
800
+ ...(record.errors ?? []).filter((line) => !/总执行超时/.test(String(line))),
801
+ record.error,
802
+ ].slice(-20);
803
+ }
804
+ else {
805
+ /*
806
+ * `blocked` is a terminal state, so it always reports progress 100 — the
807
+ * same rule the other four blocked writers follow. Carrying the partial
808
+ * number over (`item.progress ?? 0`) made one failure show up as 0 %, 15 %
809
+ * or 100 % depending on which branch happened to write it.
810
+ */
811
+ record.statuses = record.statuses.map((item) => {
812
+ if (item.status === 'pending' || item.status === 'in_progress') {
813
+ return {
814
+ ...item,
815
+ status: 'blocked',
816
+ progress: 100,
817
+ error: truncate(message, 2000),
818
+ finishedAt: item.finishedAt ?? Date.now(),
819
+ };
820
+ }
821
+ return item;
822
+ });
823
+ record.phase = parentSignal.aborted ? 'cancelled' : 'blocked';
824
+ }
825
+ }
826
+ finally {
827
+ clearTimeout(totalTimer);
828
+ parentSignal.removeEventListener('abort', onAbort);
829
+ if (!childController.signal.aborted)
830
+ childController.abort();
831
+ record.updatedAt = Date.now();
832
+ }
833
+ return record;
834
+ }
835
+ function serialisePlan(record, session) {
836
+ const plan = record.plan ?? null;
837
+ /*
838
+ * Counts are derived here, once, from the very array that is shipped.
839
+ *
840
+ * `stats` used to be read from `record.stats`, a cache refreshed only between
841
+ * dispatch batches, while `departments` below was aggregated live. The same
842
+ * payload could therefore report “0 正在做” in the header while a stage badge
843
+ * built from `statuses` said otherwise. Deriving both from one normalised
844
+ * snapshot makes that contradiction structurally impossible.
845
+ */
846
+ const snapshot = derivePlanSnapshot(Array.isArray(record.statuses) && record.statuses.length > 0
847
+ ? record.statuses
848
+ : plan
849
+ ? createStatuses(plan)
850
+ : []);
851
+ const statuses = snapshot.statuses;
852
+ const stats = snapshot.stats;
853
+ let phase = record.phase ?? (plan ? planPhase(statuses) : 'degraded');
854
+ if (phase === 'pending' && !record.dispatchedAt && plan?.createdAt && Date.now() - plan.createdAt > STALE_PENDING_MS) {
855
+ phase = 'cancelled';
856
+ }
857
+ return {
858
+ sessionId: session.id,
859
+ planId: record.planId ?? plan?.planId ?? null,
860
+ title: plan?.title ?? record.title ?? '任务进度',
861
+ phase,
862
+ degraded: record.degraded === true,
863
+ error: record.error ? truncate(record.error, 2000) : null,
864
+ errors: (record.errors ?? []).slice(-20),
865
+ createdAt: record.createdAt ?? plan?.createdAt ?? session.header.createdAt,
866
+ updatedAt: record.updatedAt ?? Date.now(),
867
+ createdTurn: plan?.createdTurn ?? record.createdTurn ?? null,
868
+ plan,
869
+ statuses,
870
+ stats,
871
+ /*
872
+ * Liveness and resume bookkeeping. Without these the dashboard could not
873
+ * tell a slow-but-alive child from a wedged one, and the model could not
874
+ * tell whether dispatch would actually run anything.
875
+ */
876
+ heartbeatAt: record.heartbeatAt ?? null,
877
+ resumeCount: Number(record.resumeCount ?? 0),
878
+ resumeLimit: Number(record.resumeLimit ?? MAX_PLAN_RESUMES),
879
+ resumeAvailable: record.plan
880
+ ? canResumePlan(record, Number(record.resumeLimit ?? MAX_PLAN_RESUMES)).resume
881
+ : false,
882
+ dispatchElapsedMs: record.dispatchStartedAt ? Date.now() - record.dispatchStartedAt : null,
883
+ departments: snapshot.departments,
884
+ taskUnderstanding: plan?.taskUnderstanding ?? record.taskUnderstanding ?? null,
885
+ contradictions: plan?.contradictions ?? null,
886
+ stages: plan?.stages ?? [],
887
+ risks: plan?.risks ?? [],
888
+ verification: plan?.verification ?? [],
889
+ practiceLoop: plan?.practiceLoop ?? null,
890
+ };
891
+ }
892
+ function readToolArguments(value) {
893
+ if (isRecord(value))
894
+ return value;
895
+ if (typeof value === 'string' && value.trim() !== '') {
896
+ try {
897
+ const parsed = JSON.parse(value);
898
+ return isRecord(parsed) ? parsed : null;
899
+ }
900
+ catch {
901
+ return null;
902
+ }
903
+ }
904
+ return null;
905
+ }
906
+ export function reconstructFromSession(session, targetPlanId) {
907
+ const start = Math.max(0, session.seq - MAX_HISTORY_EVENTS);
908
+ let plan = null;
909
+ let statuses = [];
910
+ let degraded = null;
911
+ let resultSeen = false;
912
+ // A round-budget pause must survive a page refresh; otherwise the replayed
913
+ // plan looks “executing” forever (nothing is running) and the card stops
914
+ // telling the user it can simply be dispatched again.
915
+ let resultPhase = '';
916
+ let cancelled = false;
917
+ let resumeCount = 0;
918
+ /*
919
+ * Older plans stay dispatchable.
920
+ *
921
+ * The replayed state used to be "whatever the newest plan message says", so a
922
+ * plan paused before a restart became unreachable the moment the next turn
923
+ * generated a new plan: the rebuild answered with the new plan and every
924
+ * dispatch of the old one failed with PLAN_ID_MISMATCH. Keep a snapshot per
925
+ * plan and let the caller ask for a specific one.
926
+ */
927
+ const finished = [];
928
+ const snapshot = () => ({ plan, statuses, resultPhase, resultSeen, resumeCount, cancelled });
929
+ for (let seq = start; seq < session.seq; seq += 1) {
930
+ let event;
931
+ try {
932
+ event = session.eventAt(seq);
933
+ }
934
+ catch {
935
+ continue;
936
+ }
937
+ if (!event)
938
+ continue;
939
+ if (event.type === 'user/message') {
940
+ const payload = parseWorkplanMessage(event.data);
941
+ if (payload?.degraded) {
942
+ degraded = payload;
943
+ plan = null;
944
+ statuses = [];
945
+ cancelled = false;
946
+ }
947
+ else if (payload?.plan) {
948
+ if (plan)
949
+ finished.push(snapshot());
950
+ plan = payload.plan;
951
+ statuses = createStatuses(plan);
952
+ cancelled = false;
953
+ /*
954
+ * A new plan must not inherit the previous plan's round phase.
955
+ *
956
+ * `resultPhase` outlived the old plan, so after a paused plan the next
957
+ * plan in the same session replayed as `paused` before its first
958
+ * dispatch — the card claimed the new plan was out of budget when it had
959
+ * not started. Only a result message for *this* plan may set it.
960
+ */
961
+ resultPhase = '';
962
+ resultSeen = false;
963
+ resumeCount = 0;
964
+ }
965
+ const result = parseResultMessage(event.data);
966
+ if (result && plan && result.planId === plan.planId) {
967
+ statuses = applyResultStatuses(statuses, result);
968
+ // Resume bookkeeping must survive a page refresh, otherwise the budget
969
+ // silently resets and a never-succeeding subtask can loop forever.
970
+ resumeCount = Number(result.resumeCount ?? resumeCount) || 0;
971
+ resultPhase = typeof result.phase === 'string' ? result.phase : '';
972
+ resultSeen = true;
973
+ }
974
+ const cancel = parseCancelMessage(event.data);
975
+ if (cancel && plan && cancel.planId === plan.planId) {
976
+ cancelled = true;
977
+ }
978
+ }
979
+ else if (event.type === 'tool/call' && plan) {
980
+ const data = event.data;
981
+ if (data?.name === 'dsh_workplan_update') {
982
+ const args = readToolArguments(data.arguments);
983
+ if (args && typeof args.subtaskId === 'string' && typeof args.status === 'string') {
984
+ try {
985
+ statuses = applyStatus(statuses, args.subtaskId, {
986
+ status: args.status,
987
+ progress: typeof args.progress === 'number' ? args.progress : undefined,
988
+ summary: typeof args.note === 'string' ? args.note : undefined,
989
+ });
990
+ }
991
+ catch {
992
+ // An invalid historical call is ignored; the live cache remains authoritative.
993
+ }
994
+ }
995
+ }
996
+ }
997
+ }
998
+ if (!plan && !degraded)
999
+ return null;
1000
+ if (degraded) {
1001
+ return {
1002
+ plan: null,
1003
+ planId: degraded.planId ?? null,
1004
+ title: degraded.title ?? '自动分解降级',
1005
+ degraded: true,
1006
+ error: degraded.error ?? null,
1007
+ statuses: [],
1008
+ phase: 'degraded',
1009
+ createdAt: degraded.createdAt ?? session.header.createdAt,
1010
+ updatedAt: degraded.createdAt ?? session.header.createdAt,
1011
+ createdTurn: degraded.createdTurn ?? null,
1012
+ };
1013
+ }
1014
+ if (!plan)
1015
+ return null;
1016
+ if (targetPlanId && plan.planId !== targetPlanId) {
1017
+ const hit = finished.find((item) => item.plan?.planId === targetPlanId);
1018
+ if (!hit)
1019
+ return null;
1020
+ ({ plan, statuses, resultPhase, resultSeen, resumeCount, cancelled } = hit);
1021
+ }
1022
+ let phase = planPhase(statuses, resultSeen);
1023
+ if (resultPhase === 'paused' && statuses.some((item) => item.status !== 'done'))
1024
+ phase = 'paused';
1025
+ if (cancelled)
1026
+ phase = 'cancelled';
1027
+ /*
1028
+ * An old plan that was never dispatched used to be relabelled `cancelled`,
1029
+ * which also made it permanently unresumable — the card said "已取消" and no
1030
+ * amount of dispatching would move it. Keep it `pending`, and expose the age
1031
+ * as a separate flag so the UI can prompt instead of silently closing it.
1032
+ */
1033
+ const stalePending = phase === 'pending' && Boolean(plan.createdAt)
1034
+ && Date.now() - Number(plan.createdAt) > STALE_PENDING_MS;
1035
+ return {
1036
+ plan,
1037
+ planId: plan.planId,
1038
+ title: plan.title,
1039
+ degraded: false,
1040
+ error: null,
1041
+ statuses,
1042
+ phase,
1043
+ createdAt: plan.createdAt,
1044
+ updatedAt: Date.now(),
1045
+ createdTurn: plan.createdTurn,
1046
+ resumeCount,
1047
+ resumeLimit: MAX_PLAN_RESUMES,
1048
+ resumeAvailable: canResumePlan({ phase, statuses, resumeCount }, MAX_PLAN_RESUMES).resume,
1049
+ stale: stalePending,
1050
+ };
1051
+ }
1052
+ function liveSessions(ctx) {
1053
+ try {
1054
+ const sessions = ctx.sessions?.list?.();
1055
+ return Array.isArray(sessions) ? sessions : [];
1056
+ }
1057
+ catch {
1058
+ return [];
1059
+ }
1060
+ }
1061
+ function findSession(ctx, sessionId) {
1062
+ try {
1063
+ const session = ctx.sessions?.get?.(sessionId) ?? null;
1064
+ return session && isRootSession(session) ? session : null;
1065
+ }
1066
+ catch {
1067
+ return null;
1068
+ }
1069
+ }
1070
+ export function buildPlansPayload(ctx, state, sessionId) {
1071
+ let sessions = liveSessions(ctx).filter((session) => isRootSession(session));
1072
+ if (sessionId) {
1073
+ const match = sessions.find((session) => session.id === sessionId) ?? findSession(ctx, sessionId);
1074
+ sessions = match ? [match] : [];
1075
+ }
1076
+ sessions.sort((a, b) => (b.header?.createdAt ?? 0) - (a.header?.createdAt ?? 0));
1077
+ sessions = sessions.slice(0, MAX_LIVE_SESSIONS);
1078
+ const items = [];
1079
+ for (const session of sessions) {
1080
+ let record = state.records.get(session.id);
1081
+ if (!record)
1082
+ record = reconstructFromSession(session);
1083
+ if (!record)
1084
+ continue;
1085
+ items.push(serialisePlan(record, session));
1086
+ }
1087
+ return { ok: true, now: Date.now(), sessions: items };
1088
+ }
1089
+ function respondJson(res, status, payload) {
1090
+ const body = JSON.stringify(payload);
1091
+ res.writeHead(status, {
1092
+ 'content-type': 'application/json; charset=utf-8',
1093
+ 'cache-control': 'no-store',
1094
+ 'content-length': Buffer.byteLength(body),
1095
+ });
1096
+ if (res.req?.method === 'HEAD') {
1097
+ res.end();
1098
+ return;
1099
+ }
1100
+ res.end(body);
1101
+ }
1102
+ export function createWorkplanRuntime(ctx, options = {}) {
1103
+ const state = { records: new Map() };
1104
+ /*
1105
+ * Resolve the tunables once, at construction, so every code path (dispatch,
1106
+ * resume, cancel, the host's tool timeout) agrees on the same numbers.
1107
+ */
1108
+ const limits = {
1109
+ childTimeoutMs: normaliseChildTimeout(options.childTimeoutMs),
1110
+ idleTimeoutMs: normaliseIdleTimeout(options.idleTimeoutMs),
1111
+ totalTimeoutMs: Number.isFinite(Number(options.totalTimeoutMs)) && Number(options.totalTimeoutMs) > 0
1112
+ ? Math.max(60000, Math.floor(Number(options.totalTimeoutMs)))
1113
+ : TOTAL_TIMEOUT_MS,
1114
+ maxResumes: Number.isFinite(Number(options.maxResumes)) && Number(options.maxResumes) >= 0
1115
+ ? Math.min(5, Math.floor(Number(options.maxResumes)))
1116
+ : MAX_PLAN_RESUMES,
1117
+ };
1118
+ async function onPreStep(payload, next) {
1119
+ let downstream;
1120
+ try {
1121
+ downstream = await next();
1122
+ }
1123
+ catch (error) {
1124
+ throw error;
1125
+ }
1126
+ if (downstream?.kind !== 'enter')
1127
+ return downstream;
1128
+ try {
1129
+ return await maybePlan(ctx, state, payload, downstream, options);
1130
+ }
1131
+ catch (error) {
1132
+ try {
1133
+ const session = payload.agent?.session;
1134
+ const planId = `wp-${randomUUID()}`;
1135
+ const message = buildDegradedMessage({
1136
+ planId,
1137
+ sessionId: session?.id ?? 'unknown',
1138
+ createdTurn: payload.turn,
1139
+ createdAt: Date.now(),
1140
+ error: error instanceof Error ? error.message : String(error),
1141
+ taskText: taskTextFromMessages(payload.messages),
1142
+ });
1143
+ if (session) {
1144
+ state.records.set(session.id, {
1145
+ planId,
1146
+ plan: null,
1147
+ title: '自动分解降级',
1148
+ degraded: true,
1149
+ error: error instanceof Error ? error.message : String(error),
1150
+ statuses: [],
1151
+ phase: 'degraded',
1152
+ errors: [truncate(String(error), 1000)],
1153
+ createdAt: Date.now(),
1154
+ updatedAt: Date.now(),
1155
+ createdTurn: payload.turn,
1156
+ });
1157
+ }
1158
+ return enterDecision(downstream, [message]);
1159
+ }
1160
+ catch {
1161
+ return downstream;
1162
+ }
1163
+ }
1164
+ }
1165
+ async function updateStatus(agent, args) {
1166
+ const session = agent?.session;
1167
+ if (!session)
1168
+ throw new PlanError('只能在会话内更新计划', 'NO_SESSION');
1169
+ if (!isRootSession(session))
1170
+ throw new PlanError('子 Agent 不能更新主计划', 'NOT_ROOT');
1171
+ const record = state.records.get(session.id);
1172
+ if (!record?.plan)
1173
+ throw new PlanError('当前会话还没有已生成的计划', 'NO_PLAN');
1174
+ if (args?.planId && record.plan.planId !== args.planId) {
1175
+ throw new PlanError('planId 与当前计划不匹配', 'PLAN_ID_MISMATCH');
1176
+ }
1177
+ const subtaskId = typeof args?.subtaskId === 'string' ? args.subtaskId : '';
1178
+ if (!subtaskId)
1179
+ throw new PlanError('缺少 subtaskId', 'MISSING_SUBTASK');
1180
+ const status = typeof args?.status === 'string' ? args.status : '';
1181
+ if (!['pending', 'in_progress', 'done', 'blocked'].includes(status)) {
1182
+ throw new PlanError('status 必须是 pending / in_progress / done / blocked', 'INVALID_STATUS');
1183
+ }
1184
+ const note = typeof args?.note === 'string' ? args.note : '';
1185
+ const evidence = Array.isArray(args?.evidence)
1186
+ ? args.evidence.filter((item) => typeof item === 'string').join(';')
1187
+ : '';
1188
+ const summary = [note, evidence].filter(Boolean).join('|');
1189
+ record.statuses = applyStatus(record.statuses, subtaskId, {
1190
+ status,
1191
+ progress: typeof args?.progress === 'number' ? args.progress : undefined,
1192
+ summary: summary || undefined,
1193
+ error: status === 'blocked' ? (summary || '由主 Agent 标记为受阻') : undefined,
1194
+ });
1195
+ if (status === 'blocked') {
1196
+ record.statuses = markDependentsBlocked(record.plan, record.statuses, subtaskId);
1197
+ }
1198
+ record.phase = planPhase(record.statuses);
1199
+ record.updatedAt = Date.now();
1200
+ const snapshot = derivePlanSnapshot(record.statuses);
1201
+ return {
1202
+ ok: true,
1203
+ planId: record.plan.planId,
1204
+ subtaskId,
1205
+ status: snapshot.statuses.find((item) => item.subtaskId === subtaskId)?.status ?? status,
1206
+ stats: snapshot.stats,
1207
+ };
1208
+ }
1209
+ async function cancelPlan(agent, args) {
1210
+ const session = agent?.session;
1211
+ if (!session)
1212
+ throw new PlanError('只能在会话内取消计划', 'NO_SESSION');
1213
+ if (!isRootSession(session))
1214
+ throw new PlanError('子 Agent 不能取消主计划', 'NOT_ROOT');
1215
+ let record = state.records.get(session.id);
1216
+ if (!record?.plan)
1217
+ record = reconstructFromSession(session);
1218
+ if (!record?.plan)
1219
+ throw new PlanError('当前会话还没有已生成的计划', 'NO_PLAN');
1220
+ if (args?.planId && record.plan.planId !== args.planId) {
1221
+ throw new PlanError('planId 与当前计划不匹配', 'PLAN_ID_MISMATCH');
1222
+ }
1223
+ const reason = typeof args?.reason === 'string' && args.reason.trim()
1224
+ ? args.reason.trim()
1225
+ : '用户取消';
1226
+ if (record.dispatchPromise) {
1227
+ /*
1228
+ * Cancel must always work.
1229
+ *
1230
+ * Refusing with "计划正在执行,不能取消" meant a wedged dispatch could not
1231
+ * be stopped at all: the tool that would stop it refused while the thing
1232
+ * it needed to stop was running. Abort the running dispatch and let it
1233
+ * settle as cancelled; the in-flight result message is still written, so
1234
+ * no partial work is lost.
1235
+ */
1236
+ record.phase = 'cancelled';
1237
+ record.error = `计划已取消:${reason}`;
1238
+ record.updatedAt = Date.now();
1239
+ let aborted = false;
1240
+ try {
1241
+ record.abortDispatch?.(reason);
1242
+ aborted = true;
1243
+ }
1244
+ catch (error) {
1245
+ record.errors = [...(record.errors ?? []), `取消时中止执行失败:${String(error?.message ?? error)}`].slice(-20);
1246
+ }
1247
+ return {
1248
+ ok: true,
1249
+ planId: record.plan.planId,
1250
+ phase: 'cancelled',
1251
+ reason,
1252
+ abortedRunning: aborted,
1253
+ stats: derivePlanSnapshot(record.statuses).stats,
1254
+ };
1255
+ }
1256
+ record.phase = 'cancelled';
1257
+ record.error = `计划已取消:${reason}`;
1258
+ record.updatedAt = Date.now();
1259
+ const message = buildCancelMessage(record.plan.planId, reason);
1260
+ session.append('user/message', message, { surfaceOp: 'append' });
1261
+ state.records.set(session.id, record);
1262
+ return {
1263
+ ok: true,
1264
+ planId: record.plan.planId,
1265
+ phase: 'cancelled',
1266
+ reason,
1267
+ stats: derivePlanSnapshot(record.statuses).stats,
1268
+ };
1269
+ }
1270
+ async function dispatchTool(agent, args, signal) {
1271
+ const session = agent?.session;
1272
+ if (!session)
1273
+ throw new PlanError('只能在会话内派发计划', 'NO_SESSION');
1274
+ if (!isRootSession(session))
1275
+ throw new PlanError('子 Agent 不能派发主计划', 'NOT_ROOT');
1276
+ let record = state.records.get(session.id);
1277
+ const requestedPlanId = typeof args?.planId === 'string' ? args.planId : undefined;
1278
+ /*
1279
+ * Rebuild when memory has no record **or** when the caller names a plan
1280
+ * that memory does not hold.
1281
+ *
1282
+ * 1.3.6 only covered the empty-memory case. The real restart test then hit
1283
+ * the other half: a restart does empty memory, but the very next turn's
1284
+ * planner immediately builds a *new* plan, so by the time the user asks for
1285
+ * the old paused plan memory holds the new one and the dispatch answered
1286
+ * `planId 与当前计划不匹配` — exactly the same dead end, one layer down.
1287
+ * A named plan that exists in the session log is dispatchable, whoever is
1288
+ * currently in memory.
1289
+ */
1290
+ const needsRebuild = !record?.plan || Boolean(requestedPlanId && record.plan.planId !== requestedPlanId);
1291
+ if (needsRebuild && !record?.dispatchPromise) {
1292
+ /*
1293
+ * A restarted host has no in-memory record, but the plan is still in the
1294
+ * session log.
1295
+ *
1296
+ * This used to answer `NO_PLAN` — “当前会话没有可派发的计划” — for a plan
1297
+ * the user could see on the card. Restarting `dsh web` (the exact step we
1298
+ * ask for when verifying a fix) therefore made a paused or blocked plan
1299
+ * permanently unresumable, which is a stall of the worst kind: the work is
1300
+ * finished on disk, and nothing can continue it.
1301
+ */
1302
+ const rebuilt = reconstructFromSession(session, requestedPlanId);
1303
+ if (rebuilt?.plan) {
1304
+ rebuilt.resumeLimit = limits.maxResumes;
1305
+ state.records.set(session.id, rebuilt);
1306
+ record = rebuilt;
1307
+ }
1308
+ }
1309
+ if (!record?.plan)
1310
+ throw new PlanError('当前会话没有可派发的计划', 'NO_PLAN');
1311
+ if (args?.planId && record.plan.planId !== args.planId) {
1312
+ throw new PlanError('planId 与当前计划不匹配', 'PLAN_ID_MISMATCH');
1313
+ }
1314
+ if (record.dispatchPromise) {
1315
+ /*
1316
+ * "Already running" must not become a permanent answer.
1317
+ *
1318
+ * If a previous dispatch promise never settles (a wedged child service
1319
+ * that ignored its abort signal), every later dispatch would report
1320
+ * success-with-nothing-done forever, and the only escape was a new
1321
+ * conversation. After the dispatch budget plus a grace period the plugin
1322
+ * stops claiming the plan is running and hands the work back instead.
1323
+ */
1324
+ const startedAt = Number(record.dispatchStartedAt ?? 0);
1325
+ const staleAfter = limits.totalTimeoutMs + 120000;
1326
+ if (startedAt > 0 && Date.now() - startedAt > staleAfter) {
1327
+ return {
1328
+ ok: false,
1329
+ planId: record.plan.planId,
1330
+ alreadyRunning: true,
1331
+ stale: true,
1332
+ startedAt,
1333
+ elapsedMs: Date.now() - startedAt,
1334
+ phase: record.phase,
1335
+ stats: derivePlanSnapshot(record.statuses).stats,
1336
+ error: `上一次派发已持续 ${Math.round((Date.now() - startedAt) / 1000)}s 仍未返回,判定为已卡住`,
1337
+ hint: '请主 Agent 直接接手完成剩余子任务,不要再次派发;并在答复中说明本次是直接执行而非派发。',
1338
+ };
1339
+ }
1340
+ return { ok: true, planId: record.plan.planId, alreadyRunning: true, phase: record.phase, startedAt: startedAt || null };
1341
+ }
1342
+ /*
1343
+ * Refuse with an explanation instead of silently "succeeding".
1344
+ *
1345
+ * Re-dispatching an all-blocked plan used to fall straight through to
1346
+ * `readySubtasks()`, find nothing ready, and answer `{ ok: true }` without
1347
+ * running a single child. The model read that as success, so a plan that hit
1348
+ * one timeout could never move again.
1349
+ */
1350
+ const resume = canResumePlan(record, limits.maxResumes);
1351
+ if (record.phase === 'blocked' && resume.failed > 0 && !resume.resume) {
1352
+ return {
1353
+ ok: false,
1354
+ planId: record.plan.planId,
1355
+ phase: record.phase,
1356
+ resume,
1357
+ stats: derivePlanSnapshot(record.statuses).stats,
1358
+ error: resume.reason,
1359
+ hint: '这些子任务不会再自动重跑,请主 Agent 直接完成它们,并在答复中如实说明哪些未能自动完成。',
1360
+ };
1361
+ }
1362
+ record.dispatchStartedAt = Date.now();
1363
+ record.resumeLimit = limits.maxResumes;
1364
+ const selection = {
1365
+ provider: agent?.options?.provider,
1366
+ model: agent?.options?.model,
1367
+ reasoningEffort: agent?.options?.reasoningEffort,
1368
+ };
1369
+ /*
1370
+ * The dispatch runs under its own controller so a user cancel can actually
1371
+ * stop it. Passing the tool's abort signal alone was not enough: cancelling
1372
+ * while running was refused outright, which stranded a wedged dispatch.
1373
+ */
1374
+ const dispatchController = new AbortController();
1375
+ const onToolAbort = () => dispatchController.abort(signal?.reason ?? abortError());
1376
+ if (signal?.aborted)
1377
+ onToolAbort();
1378
+ else
1379
+ signal?.addEventListener?.('abort', onToolAbort, { once: true });
1380
+ record.abortDispatch = (reason) => {
1381
+ if (!dispatchController.signal.aborted) {
1382
+ dispatchController.abort(new PlanError(`计划已取消:${reason}`, 'CANCELLED'));
1383
+ }
1384
+ };
1385
+ record.dispatchPromise = dispatchPlan(ctx, record, {
1386
+ agent,
1387
+ signal: dispatchController.signal,
1388
+ modelSelection: selection.provider && selection.model ? selection : record.modelSelection,
1389
+ cwd: session.header?.cwd ?? options.cwd,
1390
+ conversationContext: record.conversationContext ?? '',
1391
+ childTimeoutMs: limits.childTimeoutMs,
1392
+ idleTimeoutMs: limits.idleTimeoutMs,
1393
+ totalTimeoutMs: limits.totalTimeoutMs,
1394
+ maxResumes: limits.maxResumes,
1395
+ }).finally(() => {
1396
+ record.dispatchPromise = null;
1397
+ record.dispatchStartedAt = null;
1398
+ record.abortDispatch = null;
1399
+ signal?.removeEventListener?.('abort', onToolAbort);
1400
+ });
1401
+ await record.dispatchPromise;
1402
+ if (!record.resultWritten) {
1403
+ const resultMessage = buildRoundResult(record, session.id, limits);
1404
+ try {
1405
+ session.append('user/message', resultMessage, { surfaceOp: 'append' });
1406
+ record.resultWritten = true;
1407
+ }
1408
+ catch (error) {
1409
+ ctx.logger?.warn?.(`dsh-workplan: 写入结果消息失败:${error instanceof Error ? error.message : String(error)}`);
1410
+ }
1411
+ }
1412
+ if (record.resultWritten && record.statuses.length > 0 && record.statuses.every((item) => item.status === 'done')) {
1413
+ record.phase = 'done';
1414
+ }
1415
+ /*
1416
+ * The model's dispatch result reports the same snapshot the dashboard will
1417
+ * show. It used to send `record.stats` while the status list below was read
1418
+ * fresh, so one reply could say “已完成 11” next to twelve finished rows.
1419
+ */
1420
+ const snapshot = derivePlanSnapshot(record.statuses);
1421
+ return {
1422
+ ok: true,
1423
+ planId: record.plan.planId,
1424
+ alreadyRunning: false,
1425
+ phase: record.phase,
1426
+ stats: snapshot.stats,
1427
+ statuses: snapshot.statuses.map((item) => ({
1428
+ subtaskId: item.subtaskId,
1429
+ title: item.title,
1430
+ department: item.department,
1431
+ status: item.status,
1432
+ progress: item.progress,
1433
+ summary: truncate(item.summary ?? item.error ?? '', 2000),
1434
+ error: item.error ?? null,
1435
+ })),
1436
+ };
1437
+ }
1438
+ function plansPayload(sessionId) {
1439
+ return buildPlansPayload(ctx, state, sessionId);
1440
+ }
1441
+ async function route(req, res, connectionCtx = ctx) {
1442
+ let rejection;
1443
+ try {
1444
+ rejection = await Promise.resolve(connectionCtx?.connection?.requestRejection?.(req));
1445
+ }
1446
+ catch {
1447
+ respondJson(res, 403, { ok: false, error: '未授权的请求' });
1448
+ return;
1449
+ }
1450
+ if (rejection) {
1451
+ respondJson(res, typeof rejection === 'number' ? rejection : 401, {
1452
+ ok: false,
1453
+ error: '未授权的请求',
1454
+ });
1455
+ return;
1456
+ }
1457
+ let url;
1458
+ try {
1459
+ url = new URL(req.url ?? '/', 'http://localhost');
1460
+ }
1461
+ catch {
1462
+ respondJson(res, 400, { ok: false, error: '无效的请求地址' });
1463
+ return;
1464
+ }
1465
+ if (url.pathname !== '/dsh-workplan/plans') {
1466
+ respondJson(res, 404, { ok: false, error: '未找到' });
1467
+ return;
1468
+ }
1469
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1470
+ res.writeHead(405, { allow: 'GET, HEAD', 'content-type': 'application/json; charset=utf-8' });
1471
+ res.end();
1472
+ return;
1473
+ }
1474
+ try {
1475
+ const payload = plansPayload(url.searchParams.get('sessionId') ?? undefined);
1476
+ const body = JSON.stringify(payload);
1477
+ if (body.length > 2_500_000) {
1478
+ respondJson(res, 200, { ...payload, truncated: true, sessions: payload.sessions.slice(0, 12) });
1479
+ return;
1480
+ }
1481
+ if (req.method === 'HEAD') {
1482
+ res.writeHead(200, {
1483
+ 'content-type': 'application/json; charset=utf-8',
1484
+ 'cache-control': 'no-store',
1485
+ 'content-length': Buffer.byteLength(body),
1486
+ });
1487
+ res.end();
1488
+ return;
1489
+ }
1490
+ res.writeHead(200, {
1491
+ 'content-type': 'application/json; charset=utf-8',
1492
+ 'cache-control': 'no-store',
1493
+ 'content-length': Buffer.byteLength(body),
1494
+ });
1495
+ res.end(body);
1496
+ }
1497
+ catch (error) {
1498
+ respondJson(res, 500, {
1499
+ ok: false,
1500
+ error: truncate(error instanceof Error ? error.message : String(error), 500),
1501
+ });
1502
+ }
1503
+ }
1504
+ function dispose() {
1505
+ state.records.clear();
1506
+ }
1507
+ return {
1508
+ state,
1509
+ limits,
1510
+ onPreStep,
1511
+ updateStatus,
1512
+ cancelPlan,
1513
+ dispatchTool,
1514
+ plansPayload,
1515
+ route,
1516
+ dispose,
1517
+ planModeActive: (session) => isPlanModeActive(ctx, session),
1518
+ };
1519
+ }
1520
+ function resolveModelSelection(ctx, payload) {
1521
+ const direct = payload.modelSelection;
1522
+ if (direct?.provider && direct?.model) {
1523
+ return {
1524
+ provider: direct.provider,
1525
+ model: direct.model,
1526
+ reasoningEffort: direct.reasoningEffort,
1527
+ };
1528
+ }
1529
+ const agentOptions = payload.agent?.options;
1530
+ if (agentOptions?.provider && agentOptions?.model) {
1531
+ return {
1532
+ provider: agentOptions.provider,
1533
+ model: agentOptions.model,
1534
+ reasoningEffort: agentOptions.reasoningEffort,
1535
+ };
1536
+ }
1537
+ try {
1538
+ const current = ctx.get?.('agentDefaultModel')?.currentSelection?.();
1539
+ if (current?.provider && current?.model) {
1540
+ return {
1541
+ provider: current.provider,
1542
+ model: current.model,
1543
+ reasoningEffort: current.reasoningEffort,
1544
+ };
1545
+ }
1546
+ }
1547
+ catch {
1548
+ // The headless profile may not register a default-model service.
1549
+ }
1550
+ return null;
1551
+ }
1552
+ async function maybePlan(ctx, state, payload, downstream, options) {
1553
+ const session = payload.agent?.session;
1554
+ if (!session || !isRootSession(session))
1555
+ return downstream;
1556
+ if (payload.step !== 1)
1557
+ return downstream;
1558
+ const planMode = isPlanModeActive(ctx, session);
1559
+ const human = humanMessages(payload.messages);
1560
+ if (human.length === 0)
1561
+ return downstream;
1562
+ const taskText = taskTextFromMessages(payload.messages);
1563
+ if (!taskText)
1564
+ return downstream;
1565
+ if (isSocialMessage(taskText))
1566
+ return downstream;
1567
+ if (isVagueContinuation(taskText))
1568
+ return downstream;
1569
+ if (isStatusQuestion(taskText))
1570
+ return downstream;
1571
+ const conversationContext = recentConversation(session);
1572
+ const existing = state.records.get(session.id);
1573
+ if (existing?.plan?.createdTurn === payload.turn)
1574
+ return downstream;
1575
+ // A plan generated in plan mode is intentionally deferred: keep it intact
1576
+ // until the user leaves plan mode and dispatches it. Cancelling it here
1577
+ // would make the plan disappear before the main Agent can act on approval.
1578
+ if (existing?.plan && existing.planMode && !existing.dispatchedAt)
1579
+ return downstream;
1580
+ // A plan committed but not yet dispatched must not be overwritten in the
1581
+ // same turn; a later turn may supersede it, otherwise one abandoned plan
1582
+ // would lock planning forever.
1583
+ if (existing?.plan && !existing.dispatchedAt) {
1584
+ if (existing.plan.createdTurn === payload.turn)
1585
+ return downstream;
1586
+ existing.phase = 'cancelled';
1587
+ existing.cancelledAt = Date.now();
1588
+ existing.updatedAt = existing.cancelledAt;
1589
+ existing.errors = Array.isArray(existing.errors) ? existing.errors : [];
1590
+ existing.errors.push('计划已取消:上一轮未派发');
1591
+ }
1592
+ if (existing?.dispatchPromise || existing?.phase === 'executing')
1593
+ return downstream;
1594
+ const modelSelection = resolveModelSelection(ctx, payload);
1595
+ if (!modelSelection?.provider || !modelSelection?.model) {
1596
+ const message = buildDegradedMessage({
1597
+ planId: `wp-${randomUUID()}`,
1598
+ sessionId: session.id,
1599
+ createdTurn: payload.turn,
1600
+ createdAt: Date.now(),
1601
+ error: '没有可用的模型路由,无法生成计划',
1602
+ taskText,
1603
+ });
1604
+ state.records.set(session.id, {
1605
+ planId: null,
1606
+ plan: null,
1607
+ title: '自动分解降级',
1608
+ degraded: true,
1609
+ error: '没有可用的模型路由',
1610
+ statuses: [],
1611
+ phase: 'degraded',
1612
+ errors: ['没有可用的模型路由'],
1613
+ createdAt: Date.now(),
1614
+ updatedAt: Date.now(),
1615
+ createdTurn: payload.turn,
1616
+ });
1617
+ return enterDecision(downstream, [message]);
1618
+ }
1619
+ let plan;
1620
+ try {
1621
+ plan = await runPlanner(ctx, {
1622
+ taskText,
1623
+ cwd: session.header?.cwd ?? options.cwd,
1624
+ sessionId: session.id,
1625
+ modelSelection,
1626
+ signal: payload.signal,
1627
+ conversationContext,
1628
+ });
1629
+ }
1630
+ catch (error) {
1631
+ if (payload.signal.aborted)
1632
+ return downstream;
1633
+ const planId = `wp-${randomUUID()}`;
1634
+ const createdAt = Date.now();
1635
+ const message = buildDegradedMessage({
1636
+ planId,
1637
+ sessionId: session.id,
1638
+ createdTurn: payload.turn,
1639
+ createdAt,
1640
+ error: error instanceof Error ? error.message : String(error),
1641
+ taskText,
1642
+ });
1643
+ state.records.set(session.id, {
1644
+ planId,
1645
+ plan: null,
1646
+ title: '自动分解降级',
1647
+ degraded: true,
1648
+ error: error instanceof Error ? error.message : String(error),
1649
+ statuses: [],
1650
+ phase: 'degraded',
1651
+ errors: [truncate(error instanceof Error ? error.message : String(error), 1000)],
1652
+ createdAt,
1653
+ updatedAt: createdAt,
1654
+ createdTurn: payload.turn,
1655
+ });
1656
+ return enterDecision(downstream, [message]);
1657
+ }
1658
+ if (payload.signal.aborted)
1659
+ return downstream;
1660
+ const planId = `wp-${randomUUID()}`;
1661
+ const createdAt = Date.now();
1662
+ const fullPlan = attachPlanMeta(plan, {
1663
+ planId,
1664
+ sessionId: session.id,
1665
+ createdTurn: payload.turn,
1666
+ createdAt,
1667
+ sourceMessageIds: human.map((message) => message.id).filter((id) => typeof id === 'string'),
1668
+ });
1669
+ const record = {
1670
+ plan: fullPlan,
1671
+ statuses: createStatuses(fullPlan),
1672
+ phase: 'pending',
1673
+ errors: [],
1674
+ createdAt,
1675
+ updatedAt: createdAt,
1676
+ dispatchedAt: null,
1677
+ dispatchPromise: null,
1678
+ modelSelection,
1679
+ conversationContext,
1680
+ planMode,
1681
+ };
1682
+ state.records.set(session.id, record);
1683
+ /*
1684
+ * Plan-first: commit the plan with this step and let the main Agent call
1685
+ * dsh_workplan_dispatch. Dispatching here would hide the plan until every
1686
+ * child finished, which is exactly the "没动静/没完成" failure. The dispatch
1687
+ * tool appends the durable result after the children finish, and tools.guard
1688
+ * stops the main Agent from doing other work before dispatching.
1689
+ */
1690
+ const planMessage = buildPlanMessage(fullPlan);
1691
+ if (!planMode)
1692
+ return enterDecision(downstream, [planMessage]);
1693
+ /*
1694
+ * Plan mode is a planning-only collaboration state: the plugin must still
1695
+ * decompose the task and show the plan, but it must not start department
1696
+ * Agents before the user approves/leaves plan mode. The main Agent is told
1697
+ * to present this plan; dsh_workplan_dispatch is separately denied while
1698
+ * plan mode is active.
1699
+ */
1700
+ const note = {
1701
+ id: randomUUID(),
1702
+ role: 'user',
1703
+ content: [{
1704
+ type: 'text',
1705
+ text: '当前处于 plan 模式:本计划只用于规划,不会启动部门 Agent。请先把上面的任务分解交给用户审阅;用户退出 plan 模式后,再调用 dsh_workplan_dispatch 执行。',
1706
+ }],
1707
+ source: { kind: 'workplan', form: 'notice', summary: 'plan 模式:计划已生成,等待用户审阅后派发' },
1708
+ };
1709
+ return enterDecision(downstream, [planMessage, note]);
1710
+ }
1711
+ export function plannerOutputSchema() {
1712
+ return {
1713
+ type: 'object',
1714
+ properties: {
1715
+ ok: { type: 'boolean' },
1716
+ planId: { type: 'string' },
1717
+ phase: { type: 'string' },
1718
+ stats: { type: 'object' },
1719
+ },
1720
+ required: ['ok'],
1721
+ additionalProperties: true,
1722
+ };
1723
+ }
1724
+ export function updateToolOutputSchema() {
1725
+ return {
1726
+ type: 'object',
1727
+ properties: {
1728
+ ok: { type: 'boolean' },
1729
+ planId: { type: 'string' },
1730
+ subtaskId: { type: 'string' },
1731
+ status: { type: 'string' },
1732
+ stats: { type: 'object' },
1733
+ },
1734
+ required: ['ok'],
1735
+ additionalProperties: true,
1736
+ };
1737
+ }
1738
+ export function dispatchToolOutputSchema() {
1739
+ return {
1740
+ type: 'object',
1741
+ properties: {
1742
+ ok: { type: 'boolean' },
1743
+ planId: { type: 'string' },
1744
+ alreadyRunning: { type: 'boolean' },
1745
+ stale: { type: 'boolean' },
1746
+ phase: { type: 'string' },
1747
+ stats: { type: 'object' },
1748
+ error: { type: 'string' },
1749
+ hint: { type: 'string' },
1750
+ resume: { type: 'object' },
1751
+ },
1752
+ required: ['ok'],
1753
+ additionalProperties: true,
1754
+ };
1755
+ }