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