dsh-agent-foreman 0.0.0-stage → 0.2.0

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/engine.js ADDED
@@ -0,0 +1,1292 @@
1
+ /**
2
+ * 包工头 · 派单引擎。
3
+ *
4
+ * 职责单一:把一组工单变成一组真实跑完的子智能体,再把产物收回来。它不知道
5
+ * 工单从哪来(父模型写的),也不判断好坏(父模型验收),只保证四件事:
6
+ *
7
+ * 1. **承包商按名册路由**——每张工单落到一个承包商,模型/推理等级由名册决定,
8
+ * 而不是父会话的模型。
9
+ * 2. **有并发上限**——同一时刻在场的承包商不超过策略值,否则会撞宿主
10
+ * `maxActiveSubagents` 的 `ACTIVATION_LIMIT_REACHED`。
11
+ * 3. **有硬超时**——墙钟到点就 abort + dispose,子智能体不会吊着一轮对话不放。
12
+ * 4. **可打回**——验收不过的工单原样回到同一个承包商,附上反馈再干一次。
13
+ *
14
+ * 只用宿主公开的 `subagents.startActivation()`:它返回 `{ childId, result, dispose }`,
15
+ * 其中 `result` 在子会话落定后 resolve 成 `{ output, stopReason, structured? }`。
16
+ * 不碰 `agent/created` 钩子、不注入 persona——承包商是"会干活的模型",不是角色扮演。
17
+ *
18
+ * @module dsh-agent-foreman/engine
19
+ */
20
+ import { ForemanError, sanitizeRunId } from './store.js';
21
+ import { readSubagentProviders } from './catalog.js';
22
+ import { createTeamRunner } from './team.js';
23
+
24
+ /**
25
+ * 把宿主抛出的激活错误翻译成"父模型看了知道下一步做什么"的一句话。
26
+ *
27
+ * 最常见的是深度上限:父智能体自己就是某个子会话时,再派一次就超过
28
+ * `maxDepth`。原始错误(`subagent depth 2 exceeds maxDepth 1`)足够排查,
29
+ * 但父模型往往不知道能改哪个旋钮,所以附上可执行的提示。
30
+ *
31
+ * @param error - 捕获到的异常。
32
+ * @returns 供看板展示的错误文本。
33
+ */
34
+ function explainActivationError(error) {
35
+ const message = error instanceof Error ? error.message : String(error);
36
+ if (/maxDepth/i.test(message)) {
37
+ return `${message}(提示:调用方自己就是子智能体,或策略里的最大嵌套层数太小。`
38
+ + '从主会话直接派单,或在设置页把最大嵌套层数调到 2)';
39
+ }
40
+ if (/ACTIVATION_LIMIT_REACHED|maxActiveSubagents/i.test(message)) {
41
+ return `${message}(提示:并行子智能体已达宿主上限,`
42
+ + '调低 concurrency 或分两轮派单)';
43
+ }
44
+ return message;
45
+ }
46
+
47
+ const STOP_LABEL = {
48
+ completed: '交付',
49
+ aborted: '被中断',
50
+ error: '出错',
51
+ 'max-tokens': '超长截断',
52
+ refusal: '拒绝执行',
53
+ };
54
+
55
+ /** 工单 id 缺省时的前缀。 */
56
+ let orderSeq = 0;
57
+
58
+ /**
59
+ * 看板里单份产物的字数上限。
60
+ *
61
+ * 这不是给用户调的旋钮:提示词要求承包商把摘要写在 2000 字以内,这个上限只是
62
+ * "提示词没管住"时的兜底。真撞上时,完整正文已经落盘(store.writeArtifact),
63
+ * 看板里只留前一段并标出总字数。
64
+ */
65
+ const ARTIFACT_LIMIT = 20000;
66
+
67
+ /**
68
+ * 从磁盘快照恢复时,未落定工单的说明。
69
+ *
70
+ * 这句话会出现在看板里、也会回到父模型手里:它必须说清"没跑"和"为什么",
71
+ * 否则父模型可能把一条中断记录当成失败结论去改代码。
72
+ */
73
+ const INTERRUPTED_NOTE = '插件重启导致中断,未自动重跑';
74
+
75
+ /** 按会话找最近一轮时扫描的快照条数:够覆盖一轮对话里的正常量。 */
76
+ const SNAPSHOT_LIMIT = 50;
77
+ /** 按显式 runId 找时扫描得更深:用户可能回头调一个更早的派单。 */
78
+ const SNAPSHOT_SCAN = 200;
79
+
80
+ /** 一小段随机 id,够在一轮对话里区分运行即可。 */
81
+ function shortId(prefix) {
82
+ return `${prefix}-${Math.random().toString(16).slice(2, 6)}`;
83
+ }
84
+
85
+ /**
86
+ * 从 ContentBlock 数组里取纯文本。
87
+ * @param output - 子智能体的最终输出。
88
+ * @returns 拼好的文本,可能是空串。
89
+ */
90
+ export function extractText(output) {
91
+ if (!Array.isArray(output))
92
+ return '';
93
+ return output
94
+ .filter(part => part !== null && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string')
95
+ .map(part => part.text)
96
+ .join('\n')
97
+ .trim();
98
+ }
99
+
100
+ /**
101
+ * 子会话模型头读不到时的看板说明。
102
+ *
103
+ * 它只解释"为什么 actualRoute 是空的":真实路由属于诊断信息,读不到不该让派单失败。
104
+ */
105
+ const ROUTE_UNREADABLE_NOTE = '读不到子会话模型头';
106
+
107
+ /**
108
+ * 从子会话头里读回**真实**使用的 provider / 模型 / 推理等级。
109
+ *
110
+ * 看板上的 `route` 是名册声明,回答"我让它用哪个模型";这里读的是宿主给子会话
111
+ * 记下的事实,回答"它到底用了哪个"。两条路都试一遍:
112
+ *
113
+ * 1. `sessions.get(childId).header`——普通激活创建出来的子会话就有;
114
+ * 2. `agents.get(childId).session.header`——已经挂上 Agent 的会话(团队模式等)。
115
+ *
116
+ * 这是纯诊断:任何一步抛错都当作"读不到",绝不上抛,免得影响派单本身。
117
+ *
118
+ * @param ctx - cordis 上下文。
119
+ * @param childId - 子会话 id(`activation.childId`)。
120
+ * @returns `{ provider, model, reasoningEffort }`;三条来源都读不到时返回 undefined。
121
+ */
122
+ export function readChildRoute(ctx, childId) {
123
+ const id = typeof childId === 'string' ? childId.trim() : '';
124
+ if (id === '' || ctx === undefined || ctx === null || typeof ctx.get !== 'function')
125
+ return undefined;
126
+ const sources = [
127
+ () => ctx.get('sessions')?.get?.(id)?.header,
128
+ () => ctx.get('agents')?.get?.(id)?.session?.header,
129
+ ];
130
+ for (const headerOf of sources) {
131
+ let header;
132
+ try {
133
+ header = headerOf();
134
+ }
135
+ catch {
136
+ // 服务没就绪 / get 本身抛错:换下一条来源。
137
+ continue;
138
+ }
139
+ const provider = typeof header?.provider === 'string' ? header.provider.trim() : '';
140
+ const model = typeof header?.model === 'string' ? header.model.trim() : '';
141
+ const reasoningEffort = typeof header?.reasoningEffort === 'string' ? header.reasoningEffort.trim() : '';
142
+ if (provider !== '' || model !== '' || reasoningEffort !== '')
143
+ return { provider, model, reasoningEffort };
144
+ }
145
+ return undefined;
146
+ }
147
+
148
+ /**
149
+ * 把一次路由回显写进工单,并和名册声明比一比。
150
+ *
151
+ * 名册里 provider / model 都为空表示"跟随父会话":这种单没有可比对的声明,
152
+ * `routeMismatch` 恒为 false——否则等于把"我允许它继承"报成不一致。
153
+ *
154
+ * @param order - 工单(就地更新)。
155
+ * @param route - {@link readChildRoute} 的结果。
156
+ * @returns 是否读到了真实路由。
157
+ */
158
+ function applyChildRoute(order, route) {
159
+ if (route === undefined) {
160
+ order.actualRoute = '';
161
+ order.actualReasoningEffort = '';
162
+ order.routeMismatch = false;
163
+ order.routeNote = ROUTE_UNREADABLE_NOTE;
164
+ return false;
165
+ }
166
+ const provider = typeof route.provider === 'string' ? route.provider.trim() : '';
167
+ const model = typeof route.model === 'string' ? route.model.trim() : '';
168
+ const member = order.member ?? {};
169
+ const declaredProvider = typeof member.provider === 'string' ? member.provider.trim() : '';
170
+ const declaredModel = typeof member.model === 'string' ? member.model.trim() : '';
171
+ order.actualRoute = provider === '' && model === '' ? '' : `${provider}/${model}`;
172
+ order.actualReasoningEffort = typeof route.reasoningEffort === 'string' ? route.reasoningEffort.trim() : '';
173
+ // 读到了路由才谈得上"不一致":actualRoute 为空表示没能确认子会话用了哪个模型,
174
+ // 这时报不一致只会制造噪音。
175
+ order.routeMismatch = declaredProvider !== '' && declaredModel !== ''
176
+ && order.actualRoute !== ''
177
+ && order.actualRoute !== `${declaredProvider}/${declaredModel}`;
178
+ order.routeNote = '';
179
+ return true;
180
+ }
181
+
182
+ /** 名字/岗位查找用的归一化:去空白、压小写。 */
183
+ function keyOf(value) {
184
+ return typeof value === 'string' ? value.trim().toLowerCase() : '';
185
+ }
186
+
187
+ /** 一个承包商声明了什么岗位(缺省时用名字兜底,和 store 的规范化一致)。 */
188
+ function roleOf(member) {
189
+ const role = typeof member?.role === 'string' ? member.role.trim() : '';
190
+ if (role !== '')
191
+ return role;
192
+ return typeof member?.name === 'string' ? member.name.trim() : '';
193
+ }
194
+
195
+ /**
196
+ * 从名册里挑出可用于派单的承包商。
197
+ *
198
+ * 岗位匹配只认**启用的**实例:给一个岗位点名,落到停用的承包商上等于派给一个
199
+ * 不存在的模型,所以过滤放在解析之前,而不是交给调度器。
200
+ *
201
+ * @param members - 名册。
202
+ * @returns 启用成员。
203
+ */
204
+ function enabledMembers(members) {
205
+ return (Array.isArray(members) ? members : []).filter(member => member !== null && typeof member === 'object' && member.enabled !== false);
206
+ }
207
+
208
+ /**
209
+ * 规范化一组工单。
210
+ *
211
+ * `dependsOn` 只支持同批次内的工单 id:跨批次的依赖在共享看板里没有意义,
212
+ * 而且会让"一轮派单"这个原子单位变得含糊。
213
+ *
214
+ * @param raw - 原始工单数组。
215
+ * @param members - 可选承包商(用来预校验 contractor 名字或岗位)。
216
+ * @returns 规范化后的工单数组。
217
+ */
218
+ export function normalizeOrders(raw, members = []) {
219
+ if (!Array.isArray(raw) || raw.length === 0)
220
+ throw new ForemanError('至少需要一张工单', 'NO_ORDERS');
221
+ if (raw.length > 12)
222
+ throw new ForemanError('一轮最多派 12 张工单;更多请拆成几轮,便于验收', 'TOO_MANY_ORDERS');
223
+ const usable = enabledMembers(members);
224
+ const known = new Set(usable.map(member => keyOf(member.id)).filter(key => key !== ''));
225
+ const names = new Map(usable.map(member => [keyOf(member.name), member.id]).filter(([key]) => key !== ''));
226
+ // 岗位也属于"能填的值":派单时可以写岗位名,由 assignContractors 在实例间轮转。
227
+ const roles = new Set(usable.map(roleOf).map(keyOf).filter(key => key !== ''));
228
+ const choices = [...new Set(usable.flatMap(member => [
229
+ typeof member.name === 'string' ? member.name.trim() : '',
230
+ roleOf(member),
231
+ ]).filter(value => value !== ''))];
232
+ const seen = new Set();
233
+ const orders = raw.map((entry, index) => {
234
+ const item = typeof entry === 'object' && entry !== null ? entry : { task: String(entry) };
235
+ const title = typeof item.title === 'string' && item.title.trim() !== ''
236
+ ? item.title.trim()
237
+ : `工单 ${index + 1}`;
238
+ const task = typeof item.task === 'string' ? item.task.trim() : '';
239
+ if (task === '')
240
+ throw new ForemanError(`工单「${title}」缺少 task`, 'INVALID_ORDER');
241
+ const id = typeof item.id === 'string' && item.id.trim() !== ''
242
+ ? item.id.trim()
243
+ : `W${++orderSeq}`;
244
+ if (seen.has(id))
245
+ throw new ForemanError(`工单 id 重复:「${id}」`, 'DUPLICATE_ORDER');
246
+ seen.add(id);
247
+ const contractor = typeof item.contractor === 'string' ? item.contractor.trim() : '';
248
+ if (contractor !== '') {
249
+ const key = keyOf(contractor);
250
+ if (!known.has(key) && !names.has(key) && !roles.has(key))
251
+ throw new ForemanError(`工单「${title}」指定的承包商「${contractor}」不在名册里。`
252
+ + `可填的名字或岗位:${choices.join('、') || '(名册为空,请先在设置页添加承包商)'}`, 'UNKNOWN_MEMBER');
253
+ }
254
+ const dependsOn = Array.isArray(item.dependsOn)
255
+ ? item.dependsOn.map(dep => String(dep)).filter(dep => dep !== '')
256
+ : [];
257
+ return {
258
+ id,
259
+ title,
260
+ task,
261
+ accept: typeof item.accept === 'string' ? item.accept.trim() : '',
262
+ // 原样留着:可能填的是名字、id 或岗位,真正的解析在 assignContractors 里做。
263
+ contractor,
264
+ dependsOn,
265
+ };
266
+ });
267
+ for (const order of orders) {
268
+ for (const dep of order.dependsOn) {
269
+ if (!orders.some(other => other.id === dep))
270
+ throw new ForemanError(`工单「${order.title}」依赖的「${dep}」不在本轮里`, 'UNKNOWN_DEPENDENCY');
271
+ }
272
+ }
273
+ return orders;
274
+ }
275
+
276
+ /**
277
+ * 把工单切成拓扑层。同层可并行,层间串行。
278
+ * @param orders - 规范化后的工单。
279
+ * @returns 层数组。
280
+ */
281
+ export function layerOrders(orders) {
282
+ const remaining = [...orders];
283
+ const done = new Set();
284
+ const layers = [];
285
+ while (remaining.length > 0) {
286
+ const ready = remaining.filter(order => order.dependsOn.every(dep => done.has(dep)));
287
+ if (ready.length === 0)
288
+ throw new ForemanError('工单依赖成环,无法排期', 'CYCLIC_DEPENDENCY');
289
+ layers.push(ready);
290
+ for (const order of ready) {
291
+ done.add(order.id);
292
+ remaining.splice(remaining.indexOf(order), 1);
293
+ }
294
+ }
295
+ return layers;
296
+ }
297
+
298
+ /**
299
+ * 生成给承包商的完整提示词。
300
+ *
301
+ * 承包商是从零开始的子会话:它看不到父会话的上下文,所以这张工单必须自足。
302
+ * 三件事缺一不可——干什么、怎么算干完、交付什么。最后一条尤其重要:父智能体
303
+ * 验收时手里只有这段文字。
304
+ *
305
+ * @param args.goal - 本轮总目标。
306
+ * @param args.order - 工单。
307
+ * @param args.feedback - 上一轮打回的意见(返工时才有)。
308
+ * @returns 提示词文本。
309
+ */
310
+ export function buildPrompt({ goal, order, feedback = '', prompt = '', history = [] }) {
311
+ const lines = [
312
+ '你是承包商,领一张工单独立完成。不要向上级提问,不要请求确认。',
313
+ ];
314
+ // 承包商自己的提示词放在最前面:它是"这个人怎么干活"的常驻说明,
315
+ // 后面的工单是"这一单要干什么"。两者冲突时以工单为准。
316
+ if (prompt !== '')
317
+ lines.push('', '【你的提示词】', prompt.trim(), '(以上是你一贯的工作方式;与本单要求冲突时,以工单为准)');
318
+ // 跨轮记忆:把同一承包商的近期产出拼进 prompt(同 agent-teams 的 previousResult)。
319
+ // 子智能体是新建的(delivery:'caller'),不带会话记忆;但把上几单的结果写进来,
320
+ // 它就能"记得"自己之前干了什么。
321
+ if (Array.isArray(history) && history.length > 0) {
322
+ lines.push('', '【你之前的产出(跨轮记忆)】');
323
+ for (const entry of history) {
324
+ lines.push(`- ${entry.id}:${entry.text.slice(0, 300)}${entry.text.length > 300 ? '…' : ''}`);
325
+ }
326
+ lines.push('(以上是你此前几单的结果,供本单参考;不要重复已完成的工作)');
327
+ }
328
+ lines.push('', `【工单】${order.id} · ${order.title}`);
329
+ if (goal !== '')
330
+ lines.push(`【总目标】${goal}`);
331
+ lines.push('', '【任务】', order.task.trim(), '', '【验收标准】',
332
+ order.accept !== '' ? order.accept : '按任务描述完成,并在交付摘要里说明如何验证。');
333
+ if (feedback !== '') {
334
+ lines.push('', '【上一轮被打回,这次必须改掉】', feedback.trim());
335
+ }
336
+ lines.push(
337
+ '',
338
+ '【交付要求】',
339
+ '1. 直接动手完成,不要提问、不要等授权;缺信息就取最合理的假设,并在摘要里写明假设。',
340
+ '2. 结束时只输出「交付摘要」,四段:做了什么 / 改了哪些文件(写全路径)/ 如何自查验证 / 遗留问题。',
341
+ '3. 摘要控制在 2000 字以内。不要贴代码、不要贴日志原文、不要复述文件内容;要我看细节就写「详见 <路径>」。',
342
+ '4. 只改与工单相关的文件,不要顺手重构。',
343
+ '5. 不要再把工作派给别的智能体,这一单由你亲手完成。',
344
+ `6. 干完之后,额外调用一次 \`foreman_report\` 工具:orderId 填「${order.id}」,output 填上面那份交付摘要。这是产物回收的保险通道——宿主可能读不到你的会话正文,只有这条上报路径能保证你的成果被收走。`,
345
+ );
346
+ return lines.join('\n');
347
+ }
348
+
349
+ /** 状态标记,供看板文本使用。 */
350
+ const STATUS_MARK = {
351
+ queued: '·',
352
+ running: '…',
353
+ done: '✔',
354
+ failed: '✖',
355
+ timeout: '✖',
356
+ skipped: '⊘',
357
+ };
358
+
359
+ /**
360
+ * 派单引擎。一个插件实例持有一个,存内存里的当前看板。
361
+ */
362
+ export class ForemanEngine {
363
+ /**
364
+ * @param deps - 插件服务与存储。
365
+ */
366
+ constructor({ ctx, store, logger }) {
367
+ this.ctx = ctx;
368
+ this.store = store;
369
+ this.logger = logger;
370
+ /** 内存看板:runId → run。一轮对话里保留最近 20 轮。 */
371
+ this.runs = new Map();
372
+ }
373
+
374
+ /** 当前的 subagents 服务(可能尚未就绪)。 */
375
+ #service() {
376
+ const service = this.ctx.get('subagents');
377
+ if (service === undefined || typeof service.startActivation !== 'function')
378
+ throw new ForemanError('宿主的 subagents 服务不可用,无法派单', 'NO_SUBAGENTS');
379
+ return service;
380
+ }
381
+
382
+ /**
383
+ * 团队模式的执行层(懒加载一次)。
384
+ *
385
+ * 宿主没装智能体团队、或服务没就绪时返回 `undefined`,调用方安静地走普通
386
+ * 激活路径——团队模式是**可选增强**,不是派单的前置条件。
387
+ * @returns runner 或 undefined。
388
+ */
389
+ /** 已建的团队成员快照(诊断用;没有团队实例时给空数组)。 */
390
+ teamSnapshot() {
391
+ try {
392
+ return this.team?.snapshot?.() ?? [];
393
+ }
394
+ catch {
395
+ return [];
396
+ }
397
+ }
398
+
399
+ async #teamLayer() {
400
+ if (this.team === undefined) {
401
+ try {
402
+ this.team = createTeamRunner({ ctx: this.ctx, logger: this.logger });
403
+ }
404
+ catch (error) {
405
+ this.logger?.warn?.(`dsh-agent-foreman: 团队模式不可用:${String(error?.message ?? error)}`);
406
+ this.team = null;
407
+ }
408
+ }
409
+ if (this.team === null || this.team === undefined)
410
+ return undefined;
411
+ return this.team.available() ? this.team : undefined;
412
+ }
413
+
414
+ /**
415
+ * 决定用哪个 subagents provider 启动子智能体。
416
+ *
417
+ * 配置名优先;宿主没注册它时退回实际注册的第一个(并记一条日志),因为
418
+ * "名字写错"比"整个插件不能用"更常见,也更不该让用户卡死。
419
+ * @param policy - 当前策略。
420
+ * @returns provider 名。
421
+ */
422
+ #providerName(policy) {
423
+ const wanted = policy.provider;
424
+ const names = readSubagentProviders(this.ctx);
425
+ if (names.length === 0)
426
+ return wanted;
427
+ if (names.includes(wanted))
428
+ return wanted;
429
+ this.logger?.warn?.(`dsh-agent-foreman: subagents provider "${wanted}" 未注册,改用 "${names[0]}"(可选:${names.join('、')})`);
430
+ return names[0];
431
+ }
432
+
433
+ /**
434
+ * 把工单分配到承包商。
435
+ *
436
+ * 指定方式优先级:**id → 名字 → 岗位**。前三者都没写就全名册轮流;轮转的
437
+ * 目的只是别让所有活压在第一个承包商身上,真正的并发控制由调度器
438
+ * (`#scheduleReady`)按 `maxParallel` 卡。
439
+ *
440
+ * 岗位是"同一类人"的池子:写「力工」时只在这个岗位**启用的**实例之间独立
441
+ * 轮转,池子里没有就报 `UNKNOWN_MEMBER`——悄悄退回全名册会把一张复核单派给
442
+ * 便宜的小工,验收就变成自说自话。
443
+ *
444
+ * @param orders - 工单。
445
+ * @param members - 已启用的承包商。
446
+ * @returns 已解析分配结果的工单。
447
+ */
448
+ static assignContractors(orders, members) {
449
+ const pool = enabledMembers(members);
450
+ if (pool.length === 0)
451
+ throw new ForemanError('名册里没有已启用的承包商。请在「设置 → 包工头」里添加,或先调用 foreman_roster 查看名册', 'EMPTY_ROSTER');
452
+ const cursors = new Map();
453
+ const rotate = (candidates, key) => {
454
+ const index = cursors.get(key) ?? 0;
455
+ cursors.set(key, index + 1);
456
+ return candidates[index % candidates.length];
457
+ };
458
+ return orders.map((order) => {
459
+ const wanted = keyOf(order.contractor);
460
+ if (wanted === '')
461
+ return { ...order, member: rotate(pool, 'all') };
462
+ // 先按 id、再按名字:两者都是精确身份,同名不同 id 时不该串人。
463
+ let member = pool.find(candidate => keyOf(candidate.id) === wanted);
464
+ if (member === undefined)
465
+ member = pool.find(candidate => keyOf(candidate.name) === wanted);
466
+ if (member !== undefined)
467
+ return { ...order, member };
468
+ const byRole = pool.filter(candidate => keyOf(roleOf(candidate)) === wanted);
469
+ if (byRole.length > 0)
470
+ return { ...order, member: rotate(byRole, `role:${wanted}`) };
471
+ const choices = [...new Set(pool.flatMap(candidate => [
472
+ typeof candidate.name === 'string' ? candidate.name.trim() : '',
473
+ roleOf(candidate),
474
+ ]).filter(value => value !== ''))];
475
+ throw new ForemanError(`工单「${order.title ?? order.id ?? '未命名'}」指定的承包商「${order.contractor}」不在名册里。`
476
+ + `可填的名字或岗位:${choices.join('、')}`, 'UNKNOWN_MEMBER');
477
+ });
478
+ }
479
+
480
+ /**
481
+ * 执行一张工单。
482
+ * @param args.order - 已分配承包商的工单。
483
+ * @param args.parent - 发起派单的父 Agent。
484
+ * @param args.policy - 派单策略。
485
+ * @param args.signal - 父调用方的取消信号。
486
+ * @returns 工单的执行结果(就地更新同一个对象)。
487
+ */
488
+ async #runOrder({ order, parent, policy, signal }) {
489
+ const member = order.member;
490
+ order.contractorName = member.name;
491
+ order.route = member.provider !== '' ? `${member.provider}/${member.model}` : 'inherit';
492
+ // 三个路由字段先给默认值:没走到激活(团队模式提前返回、起跑前就失败)的
493
+ // 工单在 orderRow / schema 里也必须是稳定的形状,而不是 undefined。
494
+ order.actualRoute = '';
495
+ order.actualReasoningEffort = '';
496
+ order.routeMismatch = false;
497
+ order.routeNote = '';
498
+ order.status = 'running';
499
+ order.updatedAt = new Date().toISOString();
500
+ const startedAt = Date.now();
501
+ const service = this.#service();
502
+ const providerName = this.#providerName(policy);
503
+ const agentOptions = member.provider !== '' && member.model !== ''
504
+ ? { provider: member.provider, model: member.model, ...(member.reasoningEffort !== '' ? { reasoningEffort: member.reasoningEffort } : {}) }
505
+ : undefined;
506
+
507
+ const controller = new AbortController();
508
+ const forwardAbort = () => controller.abort(signal?.reason);
509
+ if (signal !== undefined) {
510
+ if (signal.aborted)
511
+ controller.abort(signal.reason);
512
+ else
513
+ signal.addEventListener('abort', forwardAbort, { once: true });
514
+ }
515
+
516
+ let timer;
517
+ let timedOut = false;
518
+ let cancelled = false;
519
+ let removeAbort;
520
+ let activation;
521
+
522
+ /**
523
+ * 读一次子会话真实路由并写进工单。
524
+ *
525
+ * 包一层是为了守住"诊断绝不影响派单":`readChildRoute` 自己已经吞了异常,
526
+ * 这里再兜一次,连 `applyChildRoute` 之外的意外也不会冒到派单主流程。
527
+ */
528
+ const echoChildRoute = () => {
529
+ try {
530
+ applyChildRoute(order, readChildRoute(this.ctx, order.childId));
531
+ }
532
+ catch {
533
+ applyChildRoute(order, undefined);
534
+ }
535
+ };
536
+
537
+ // 团队模式:同一个承包商作为宿主的团队成员挂在自己会话里干活。走不通就
538
+ // 记一笔 note,继续用下面的普通激活路径——两条路的产物格式一致。
539
+ if (policy.teamMode === true) {
540
+ const team = await this.#teamLayer();
541
+ if (team === undefined)
542
+ order.teamNote = '团队模式不可用(取不到宿主的 agentTeams 服务),已退回普通模式';
543
+ else {
544
+ try {
545
+ const teamResult = await team.dispatch({
546
+ order,
547
+ parent,
548
+ prompt: buildPrompt({
549
+ goal: order.goal,
550
+ order,
551
+ feedback: order.feedback ?? '',
552
+ prompt: order.member?.prompt ?? '',
553
+ history: this.collectHistory(order.member?.id),
554
+ }),
555
+ signal: controller.signal,
556
+ policy,
557
+ });
558
+ if (teamResult.ok) {
559
+ order.teamNote = `团队模式 · ${teamResult.note}`;
560
+ order.text = teamResult.text;
561
+ order.chars = teamResult.text.length;
562
+ order.artifactPath = teamResult.text === '' ? '' : this.store.writeArtifact(order.runId ?? 'run', order.id, teamResult.text);
563
+ order.stopReason = 'completed';
564
+ order.status = 'done';
565
+ order.updatedAt = new Date().toISOString();
566
+ order.durationMs = Date.now() - startedAt;
567
+ return order;
568
+ }
569
+ order.teamNote = `团队模式失败(${teamResult.reason}),已退回普通模式`;
570
+ }
571
+ catch (error) {
572
+ const message = error instanceof Error ? error.message : String(error);
573
+ order.teamNote = `团队模式失败(${message}),已退回普通模式`;
574
+ this.logger?.warn?.(`dsh-agent-foreman: ${order.teamNote}`);
575
+ }
576
+ }
577
+ }
578
+
579
+ try {
580
+ activation = await service.startActivation({
581
+ provider: providerName,
582
+ label: `${order.id} ${order.title}`.slice(0, 80),
583
+ request: {
584
+ prompt: [{ type: 'text', text: buildPrompt({
585
+ goal: order.goal,
586
+ order,
587
+ feedback: order.feedback ?? '',
588
+ prompt: order.member?.prompt ?? '',
589
+ history: this.collectHistory(order.member?.id),
590
+ }) }],
591
+ parent,
592
+ ...(agentOptions === undefined ? {} : { agentOptions }),
593
+ maxDepth: policy.maxDepth,
594
+ },
595
+ signal: controller.signal,
596
+ delivery: 'caller',
597
+ });
598
+ order.childId = String(activation.childId);
599
+ // 起跑就先读一次:这一单万一没落定(超时/取消),看板上也有一份
600
+ // "它到底跑在哪个模型上"的证据;落定时还会再读一次并覆盖。
601
+ echoChildRoute();
602
+ const timeout = new Promise((_, reject) => {
603
+ timer = setTimeout(() => {
604
+ timedOut = true;
605
+ controller.abort(new Error('foreman timeout'));
606
+ reject(new ForemanError(`工单「${order.title}」超时(${policy.timeoutSeconds}s)`, 'TIMEOUT'));
607
+ }, policy.timeoutSeconds * 1000);
608
+ });
609
+ // 取消/预算到点也必须让这一单立刻落定:否则不配合的 provider 会让
610
+ // `Promise.race` 一直挂到单工单超时,把整轮派单拖死。
611
+ const aborted = new Promise((_, reject) => {
612
+ const onAbort = () => {
613
+ cancelled = true;
614
+ reject(new ForemanError(`工单「${order.title}」被中止(派单预算用尽或父会话取消)`, 'ABORTED'));
615
+ };
616
+ if (controller.signal.aborted) {
617
+ onAbort();
618
+ return;
619
+ }
620
+ controller.signal.addEventListener('abort', onAbort, { once: true });
621
+ removeAbort = () => controller.signal.removeEventListener('abort', onAbort);
622
+ });
623
+ const result = await Promise.race([activation.result, timeout, aborted]);
624
+ const text = extractText(result?.output);
625
+ const stopReason = typeof result?.stopReason === 'string' ? result.stopReason : 'completed';
626
+ order.stopReason = stopReason;
627
+ order.artifactPath = text === '' ? '' : this.store.writeArtifact(order.runId ?? 'run', order.id, text);
628
+ order.text = text.length > ARTIFACT_LIMIT
629
+ ? `${text.slice(0, ARTIFACT_LIMIT)}\n\n…(已截断,原文 ${text.length} 字${order.artifactPath !== '' ? `,完整正文:${order.artifactPath}` : ''})`
630
+ : text;
631
+ order.chars = text.length;
632
+ order.status = stopReason === 'completed' ? 'done' : 'failed';
633
+ if (stopReason !== 'completed')
634
+ order.error = `${STOP_LABEL[stopReason] ?? stopReason}${result?.diagnostic !== undefined ? `:${String(result.diagnostic)}` : ''}`;
635
+ }
636
+ catch (error) {
637
+ const code = error instanceof ForemanError ? error.code : undefined;
638
+ order.status = code === 'TIMEOUT' || timedOut ? 'timeout' : 'failed';
639
+ order.error = explainActivationError(error);
640
+ order.stopReason = order.stopReason ?? (timedOut || cancelled ? 'aborted' : 'error');
641
+ }
642
+ finally {
643
+ if (timer !== undefined)
644
+ clearTimeout(timer);
645
+ removeAbort?.();
646
+ if (signal !== undefined)
647
+ signal.removeEventListener?.('abort', forwardAbort);
648
+ // 落定后再读一次真实路由,覆盖起跑时那一次:子会话跑到中途才切模型
649
+ // 时,这里读到的才是它实际干完活用的那套。必须在 dispose 之前读。
650
+ if (activation !== undefined)
651
+ echoChildRoute();
652
+ // 超时/取消后必须显式释放,否则子会话会占着并发槽位。
653
+ if (activation !== undefined) {
654
+ try {
655
+ await activation.dispose();
656
+ }
657
+ catch {
658
+ // 已经落定的激活再 dispose 会失败,可以忽略。
659
+ }
660
+ }
661
+ order.durationMs = Date.now() - startedAt;
662
+ order.updatedAt = new Date().toISOString();
663
+ }
664
+ return order;
665
+ }
666
+
667
+ /**
668
+ * 派一轮工单。
669
+ *
670
+ * 这是唯一会真正等待子智能体的入口:全部工单落定后才返回,看板随结果一起
671
+ * 回到父智能体手里。若父调用方自身有等待上限(`waitSeconds`),到点就中止
672
+ * 未完成的工单——不留在后台偷偷跑,否则父模型会拿到一份假的"全部完成"。
673
+ *
674
+ * @param args.parent - 发起方 Agent。
675
+ * @param args.sessionId - 会话 id(看板归属)。
676
+ * @param args.goal - 总目标。
677
+ * @param args.orders - 已规范化的工单。
678
+ * @param args.policy - 策略。
679
+ * @param args.signal - 父调用方取消信号。
680
+ * @returns 本轮 run。
681
+ */
682
+ /**
683
+ * 收集同一承包商的近期产出(跨轮记忆)。
684
+ *
685
+ * 子智能体是新建的(delivery:'caller'),不带会话记忆。但把上几单的结果
686
+ * 拼进 prompt,它就能"记得"自己之前干了什么——这正是 agent-teams 用
687
+ * `previousResult` 实现跨轮记忆的方式。
688
+ *
689
+ * @param memberId - 承包商 id。
690
+ * @param limit - 最多取几条(默认 3)。
691
+ * @returns `[{ id,​ text }]`,从旧到新。
692
+ */
693
+ collectHistory(memberId, limit = 3) {
694
+ const out = [];
695
+ for (const run of this.runs.values()) {
696
+ for (const order of run.orders) {
697
+ if (order.member?.id !== memberId)
698
+ continue;
699
+ const text = order.reportOutput || order.text || '';
700
+ if (text === '')
701
+ continue;
702
+ out.push({ id: order.id, text, at: order.updatedAt ?? run.createdAt });
703
+ }
704
+ }
705
+ // 按时间排序取最近 limit 条(从旧到新)。
706
+ out.sort((a, b) => String(a.at).localeCompare(String(b.at)));
707
+ return out.slice(-limit);
708
+ }
709
+
710
+ async dispatch({ parent, sessionId, goal, orders, policy, signal }) {
711
+ const members = this.store.members().filter(member => member.enabled);
712
+ // attempts 从 1 起算:返工额度是 `1 + maxRework` 次总尝试,没有这个初值
713
+ // `order.attempts < 1 + maxRework` 会拿 undefined 去比,永远不返工。
714
+ const assigned = ForemanEngine.assignContractors(orders, members)
715
+ .map(order => ({ ...order, goal, attempts: 1, status: 'queued' }));
716
+ const run = {
717
+ id: shortId('R'),
718
+ goal,
719
+ sessionId,
720
+ createdAt: new Date().toISOString(),
721
+ status: 'running',
722
+ policy,
723
+ orders: assigned,
724
+ };
725
+ this.#remember(run);
726
+ for (const order of run.orders)
727
+ order.runId = run.id;
728
+ // 环检测仍然留在 layerOrders 里:它只用来拒绝成环的排期,不再是执行单位。
729
+ layerOrders(assigned);
730
+ await this.#scheduleReady({ run, parent, policy, signal });
731
+ run.status = 'waiting-review';
732
+ run.finishedAt = new Date().toISOString();
733
+ run.totalMs = run.orders.reduce((sum, order) => sum + (order.durationMs ?? 0), 0);
734
+ // 整轮落定后再存一次:工单级别的快照只保证"跑到哪儿了",这一份才是
735
+ // "这一轮结束了"的终态凭据。store 没有快照能力时 persistRun 自己跳过。
736
+ this.persistRun(run);
737
+ this.store.recordRun(this.#toLedger(run));
738
+ return run;
739
+ }
740
+
741
+ /**
742
+ * 按依赖就绪增量调度:某张单的所有 `dependsOn` 都 done 后才起跑。
743
+ *
744
+ * 和"整层 barrier"的区别只在**何时放下一张**:这里没有层,只有一张不断被
745
+ * 重新求值的就绪集合——A 交活后立刻启动依赖 A 的 C,不会白等同层慢的 B。
746
+ *
747
+ * 两个闸门照旧生效:全局 `policy.concurrency` 与单实例 `member.maxParallel`。
748
+ * 满了就排队,不拒绝。
749
+ *
750
+ * 失败链的处理是**被动递归**:一张单落定(failed/timeout/skipped)后才把它
751
+ * 的直接下游标 skipped 并写死原因,下游再轮到它自己时同样只影响它的下游。
752
+ * 这样"谁挡住了我"永远是最近的那一张,父模型看板就知道该重派哪一单。
753
+ *
754
+ * @param args.run - 本轮运行记录(工单的就地状态)。
755
+ * @param args.parent - 发起方 Agent。
756
+ * @param args.policy - 派单策略。
757
+ * @param args.signal - 取消信号。
758
+ */
759
+ async #scheduleReady({ run, parent, policy, signal }) {
760
+ const globalLimit = Math.max(1, Math.min(policy.concurrency, 8));
761
+ const byId = new Map(run.orders.map(order => [order.id, order]));
762
+ const busy = new Map();
763
+ const capacityOf = member => Math.max(1, Math.min(8, member.maxParallel ?? 1));
764
+ let active = 0;
765
+ let wake;
766
+ const notify = () => {
767
+ const pending = wake;
768
+ wake = undefined;
769
+ pending?.();
770
+ };
771
+ const idle = () => new Promise((resolve) => {
772
+ wake = resolve;
773
+ });
774
+
775
+ /** 依赖是否全部 done:只有 done 才算满足,skipped/failed 一律挡。 */
776
+ const depsSettled = order => order.dependsOn.every((depId) => {
777
+ const dep = byId.get(depId);
778
+ return dep === undefined || dep.status === 'done';
779
+ });
780
+
781
+ /** 取一个能解释"为什么被挡住"的未完成依赖 id。 */
782
+ const blockingDep = order => order.dependsOn.find((depId) => byId.get(depId)?.status !== 'done') ?? order.id;
783
+
784
+ /** 每张单落定后存一次快照;取不到 run 或 store 不支持时安静跳过。 */
785
+ const saveSnapshot = (runId) => {
786
+ const current = this.runs.get(runId);
787
+ if (current === undefined)
788
+ return;
789
+ try {
790
+ this.store?.saveRunSnapshot?.(current);
791
+ }
792
+ catch (error) {
793
+ this.logger?.warn?.(`dsh-agent-foreman: 运行快照写入失败:${String(error?.message ?? error)}`);
794
+ }
795
+ };
796
+
797
+ /** 把一张单标跳过,并把它的直接下游递归标跳过。 */
798
+ const skipOrder = (order, error) => {
799
+ order.status = 'skipped';
800
+ order.error = error;
801
+ order.stopReason = order.stopReason ?? 'skipped';
802
+ order.updatedAt = new Date().toISOString();
803
+ saveSnapshot(order.runId);
804
+ const downstream = [...byId.values()].filter(candidate => candidate.status === 'queued'
805
+ && candidate.dependsOn.includes(order.id));
806
+ for (const child of downstream)
807
+ skipOrder(child, `依赖 ${order.id} 未完成`);
808
+ };
809
+
810
+ /** 上游失败/超时后,只阻断直接依赖未满足的工单。 */
811
+ const blockDownstream = (order) => {
812
+ if (order.status === 'done')
813
+ return;
814
+ for (const child of byId.values()) {
815
+ if (child.status === 'queued' && child.dependsOn.includes(order.id))
816
+ skipOrder(child, `依赖 ${order.id} 未完成`);
817
+ }
818
+ };
819
+
820
+ /** 取消时把还没起跑的单标 skipped,说明它们不是失败而是没轮到。 */
821
+ const cancelQueued = () => {
822
+ let touched = false;
823
+ for (const order of byId.values()) {
824
+ if (order.status !== 'queued')
825
+ continue;
826
+ order.status = 'skipped';
827
+ order.error = '派单已取消';
828
+ order.stopReason = order.stopReason ?? 'aborted';
829
+ order.updatedAt = new Date().toISOString();
830
+ touched = true;
831
+ }
832
+ if (touched)
833
+ saveSnapshot(run.id);
834
+ };
835
+
836
+ const launch = (order) => {
837
+ const memberId = order.member.id;
838
+ busy.set(memberId, (busy.get(memberId) ?? 0) + 1);
839
+ active += 1;
840
+ void this.#runOrder({ order, parent, policy, signal })
841
+ .catch((error) => {
842
+ // #runOrder 自己吞掉激活错误;走到这里说明更底层出了问题,
843
+ // 也要让它落定,否则下游永远等不到结论。
844
+ order.status = 'failed';
845
+ order.error = explainActivationError(error);
846
+ order.stopReason = order.stopReason ?? 'error';
847
+ order.updatedAt = new Date().toISOString();
848
+ })
849
+ .then(() => {
850
+ busy.set(memberId, Math.max(0, (busy.get(memberId) ?? 1) - 1));
851
+ active -= 1;
852
+ blockDownstream(order);
853
+ saveSnapshot(order.runId);
854
+ notify();
855
+ });
856
+ };
857
+
858
+ while (true) {
859
+ if (signal?.aborted) {
860
+ cancelQueued();
861
+ break;
862
+ }
863
+ let launched = false;
864
+ for (const order of byId.values()) {
865
+ if (active >= globalLimit)
866
+ break;
867
+ if (order.status !== 'queued' || !depsSettled(order))
868
+ continue;
869
+ const memberId = order.member.id;
870
+ if ((busy.get(memberId) ?? 0) >= capacityOf(order.member))
871
+ continue;
872
+ launch(order);
873
+ launched = true;
874
+ }
875
+ const queued = [...byId.values()].filter(order => order.status === 'queued');
876
+ if (queued.length === 0 && active === 0)
877
+ break;
878
+ if (active === 0) {
879
+ // 没人在跑却还有单没起跑:它等的是失败/跳过的上游。正常情况下
880
+ // blockDownstream 已经处理过;这里补一次,避免空转。
881
+ const stuck = queued.find(order => !depsSettled(order));
882
+ if (stuck !== undefined) {
883
+ skipOrder(stuck, `依赖 ${blockingDep(stuck)} 未完成`);
884
+ continue;
885
+ }
886
+ throw new ForemanError('排期卡住:没有承包商还有空位(检查名册里的并发上限)', 'SCHEDULER_STUCK');
887
+ }
888
+ if (!launched)
889
+ await idle();
890
+ }
891
+ // 取消后已起跑的单仍要收尾,否则看板会留下半截 running。
892
+ while (active > 0)
893
+ await idle();
894
+ }
895
+
896
+ /**
897
+ * 把一份快照变成可读的"历史看板"。
898
+ *
899
+ * 恢复是**只读**的:这里绝不调用 `startActivation`,也不排队任何工单。插件
900
+ * 重启后重新跑一遍工单会重复改文件、重复花钱,而父模型以为自己看的是旧看板
901
+ * ——那才是真正的危险。所以未落定的工单就地判为 `failed` 并写明原因,落定的
902
+ * 结果原样保留,界面上看到的是"中断了,需要人决定要不要重派"。
903
+ *
904
+ * @param snapshot - `store.loadRunSnapshots()` 返回的一份快照。
905
+ * @returns 恢复后的运行记录。
906
+ */
907
+ #restoreRun(snapshot) {
908
+ const run = snapshot;
909
+ run.orders = (Array.isArray(run.orders) ? run.orders : []).map((order) => {
910
+ if (order === null || typeof order !== 'object')
911
+ return order;
912
+ const settled = order.status === 'done' || order.status === 'failed' || order.status === 'timeout';
913
+ if (settled)
914
+ return order;
915
+ return {
916
+ ...order,
917
+ status: 'failed',
918
+ error: INTERRUPTED_NOTE,
919
+ stopReason: order.stopReason ?? 'aborted',
920
+ };
921
+ });
922
+ if (run.status === 'running' || run.status === 'rework') {
923
+ // waiting-review 是看板本来就认识的状态:能看到、能验收、不会被当成
924
+ // 还在跑(那会让面板显示"进行中"却永远等不到结果)。
925
+ run.status = 'waiting-review';
926
+ run.interrupted = true;
927
+ }
928
+ run.recovered = true;
929
+ return run;
930
+ }
931
+
932
+ /**
933
+ * 从磁盘快照里找一轮运行;找不到返回 undefined。
934
+ *
935
+ * 显式 id 比的是**快照 id**,并且必须与会话对得上:快照是整个数据目录共享的,
936
+ * 不校验会话就等于让 A 会话用 id 读 B 会话的看板。会话为空(拿不到会话键)
937
+ * 时只允许全局查找,那是给面板/诊断用的,工具入口不会走到。
938
+ *
939
+ * @param args.runId - 显式 run id;空表示按会话找最新。
940
+ * @param args.sessionId - 会话 id。
941
+ * @param args.requireSession - 为 true 时必须提供非空会话(工具入口)。
942
+ * @returns 运行记录或 undefined。
943
+ */
944
+ #fromSnapshots({ runId, sessionId, requireSession = true }) {
945
+ const store = this.store;
946
+ if (store === undefined || typeof store.loadRunSnapshots !== 'function')
947
+ return undefined;
948
+ const wanted = typeof runId === 'string' ? runId.trim() : '';
949
+ const session = typeof sessionId === 'string' ? sessionId.trim() : '';
950
+ if (wanted !== '') {
951
+ // 空会话连显式 id 也不给:快照目录没有会话隔离,放行等于开放全局读。
952
+ if (requireSession && session === '')
953
+ return undefined;
954
+ const id = sanitizeRunId(wanted);
955
+ if (id === '')
956
+ return undefined;
957
+ const snapshot = store.loadRunSnapshots(SNAPSHOT_SCAN).find(entry => entry.id === id);
958
+ if (snapshot === undefined)
959
+ return undefined;
960
+ const owner = typeof snapshot.sessionId === 'string' ? snapshot.sessionId : '';
961
+ if (session !== '' && owner !== session) {
962
+ this.logger?.warn?.(`dsh-agent-foreman: 派单快照「${id}」属于另一个会话,已拒绝跨会话读取`);
963
+ return undefined;
964
+ }
965
+ const restored = this.#restoreRun(snapshot);
966
+ this.#remember(restored);
967
+ return restored;
968
+ }
969
+ if (requireSession && session === '')
970
+ return undefined;
971
+ const snapshots = store.loadRunSnapshots(SNAPSHOT_LIMIT);
972
+ let latest;
973
+ for (const snapshot of snapshots) {
974
+ if (session !== '' && snapshot.sessionId !== session)
975
+ continue;
976
+ const createdAt = typeof snapshot.createdAt === 'string' ? snapshot.createdAt : '';
977
+ if (latest === undefined || createdAt > latest.createdAt)
978
+ latest = snapshot;
979
+ }
980
+ if (latest === undefined)
981
+ return undefined;
982
+ const restored = this.#restoreRun(latest);
983
+ this.#remember(restored);
984
+ return restored;
985
+ }
986
+
987
+ /**
988
+ * 记录一轮运行;超过 20 轮时丢掉最老的,避免内存看板无限增长。
989
+ * @param run - 运行记录。
990
+ */
991
+ #remember(run) {
992
+ this.runs.set(run.id, run);
993
+ while (this.runs.size > 20) {
994
+ const oldest = this.runs.keys().next().value;
995
+ if (oldest === undefined)
996
+ break;
997
+ this.runs.delete(oldest);
998
+ }
999
+ }
1000
+
1001
+ /**
1002
+ * 取一轮运行:显式 runId 优先,否则取该会话最近的一轮。
1003
+ *
1004
+ * 内存里没有时回落到磁盘快照(`<dataDir>/runs/<runId>.json`):插件重载过、
1005
+ * 或者内存看板已经按 20 轮淘汰了旧记录,都还能把上一轮看板找回来读。
1006
+ * 找回来的记录是**只读**的(见 `#restoreRun`),不会重跑任何工单。
1007
+ *
1008
+ * 显式 runId 必须与 sessionId 对得上:快照目录是整个数据目录共享的,不校验
1009
+ * 会话就等于允许 A 会话用 id 读 B 会话的看板。省略 runId 时只会返回本会话的
1010
+ * 记录;会话为空不会退化成"随便给一个"。
1011
+ *
1012
+ * @param runId - 运行 id,可空。
1013
+ * @param sessionId - 会话 id,用于缺省查找与归属校验。
1014
+ * @returns 运行记录。
1015
+ */
1016
+ requireRun(runId, sessionId) {
1017
+ const wanted = typeof runId === 'string' ? runId.trim() : '';
1018
+ const session = typeof sessionId === 'string' ? sessionId.trim() : '';
1019
+ if (wanted !== '') {
1020
+ // 空会话连显式 id 也不给:快照目录没有会话隔离,放行等于开放全局读。
1021
+ if (session === '')
1022
+ throw new ForemanError('取派单记录需要会话 id,当前调用没有带上会话', 'UNKNOWN_RUN');
1023
+ const cached = this.runs.get(wanted);
1024
+ if (cached !== undefined) {
1025
+ if (cached.sessionId !== session)
1026
+ throw new ForemanError(`派单记录「${wanted}」不属于当前会话,不能在另一个会话里查看或验收`, 'UNKNOWN_RUN');
1027
+ return cached;
1028
+ }
1029
+ const restored = this.#fromSnapshots({ runId: wanted, sessionId: session });
1030
+ if (restored !== undefined)
1031
+ return restored;
1032
+ throw new ForemanError(`找不到派单记录「${wanted}」(可能已随会话结束清理)`, 'UNKNOWN_RUN');
1033
+ }
1034
+ let latest;
1035
+ for (const run of this.runs.values()) {
1036
+ // 精确匹配会话:空会话只认空会话的记录,不会退化成"谁最新给谁"。
1037
+ if (run.sessionId !== session)
1038
+ continue;
1039
+ if (latest === undefined || run.createdAt > latest.createdAt)
1040
+ latest = run;
1041
+ }
1042
+ if (latest !== undefined)
1043
+ return latest;
1044
+ // 内存 miss:只在本会话的快照里找,且必须有会话——空会话串读会把别的
1045
+ // 会话的看板当成"你这个会话的最新一轮"。
1046
+ const restored = this.#fromSnapshots({ runId: '', sessionId: session, requireSession: true });
1047
+ if (restored !== undefined)
1048
+ return restored;
1049
+ throw new ForemanError('这个会话还没有派过单', 'NO_RUN');
1050
+ }
1051
+
1052
+ /**
1053
+ * 取一轮运行,找不到就返回 undefined。
1054
+ *
1055
+ * 和 `requireRun` 的区别:这个是给 HTTP 面板用的,会话里还没派过单是正常状态,
1056
+ * 不该变成 400。内存 miss 时同样读磁盘快照,`sessionId` 为空则取全局最近一轮
1057
+ * ——这一条只服务于设置页与诊断,工具入口(`requireRun`)不允许这么读。
1058
+ *
1059
+ * @param sessionId - 会话 id;空则取全局最近一轮。
1060
+ * @returns 运行记录或 undefined。
1061
+ */
1062
+ latestRun(sessionId) {
1063
+ const session = typeof sessionId === 'string' ? sessionId.trim() : '';
1064
+ let latest;
1065
+ for (const run of this.runs.values()) {
1066
+ // 空会话 = 不过滤:面板拿不到会话键时要能看到全局最近一轮(诊断用途)。
1067
+ if (session !== '' && run.sessionId !== session)
1068
+ continue;
1069
+ if (latest === undefined || run.createdAt > latest.createdAt)
1070
+ latest = run;
1071
+ }
1072
+ if (latest !== undefined)
1073
+ return latest;
1074
+ return this.#fromSnapshots({ runId: '', sessionId: session, requireSession: false });
1075
+ }
1076
+
1077
+ /**
1078
+ * 把一轮运行存成可恢复的快照;写失败只记日志,不影响派单。
1079
+ *
1080
+ * 这是 `dispatch` / `review` 之外唯一被要求调用的持久化钩子:工单状态变化时
1081
+ * 存一次,插件重启后才有一份"跑到哪儿了"的凭据。
1082
+ *
1083
+ * @param run - 运行记录。
1084
+ * @returns 是否写入成功。
1085
+ */
1086
+ persistRun(run) {
1087
+ const store = this.store;
1088
+ if (store === undefined || typeof store.saveRunSnapshot !== 'function')
1089
+ return false;
1090
+ const ok = store.saveRunSnapshot(run);
1091
+ if (!ok)
1092
+ this.logger?.warn?.('dsh-agent-foreman: 运行快照写入失败(不影响本轮派单)');
1093
+ return ok;
1094
+ }
1095
+
1096
+ /**
1097
+ * 承包商主动上报本单的交付摘要。
1098
+ *
1099
+ * 子会话的正文宿主不一定读得到(取决于执行层),所以给它一条显式
1100
+ * 的回收通道:工单 id + 一段摘要写回看板。只认还在干的单(queued /
1101
+ * running / rework),已结案的单不收——那是验收的地盘。
1102
+ *
1103
+ * 同名工单出现在多轮里时取最新的一轮,不和历史上的同名工单串味。
1104
+ *
1105
+ * @param orderId - 工单 id。
1106
+ * @param text - 交付摘要;空白内容被忽略,不覆盖已有摘要。
1107
+ * @returns `{ ok, runId, orderId }` 或 `{ ok: false, reason }`。
1108
+ */
1109
+ reportOrderOutput(orderId, text) {
1110
+ try {
1111
+ const key = typeof orderId === 'string' ? orderId.trim() : '';
1112
+ const body = typeof text === 'string' ? text.trim() : '';
1113
+ if (key === '')
1114
+ return { ok: false, reason: '未找到进行中的工单 ' };
1115
+ const live = new Set(['queued', 'running', 'rework']);
1116
+ let hitRun;
1117
+ let hitOrder;
1118
+ for (const run of this.runs.values()) {
1119
+ if (!Array.isArray(run?.orders))
1120
+ continue;
1121
+ const order = run.orders.find(candidate => candidate?.id === key
1122
+ && live.has(candidate.status ?? 'queued'));
1123
+ if (order === undefined)
1124
+ continue;
1125
+ if (hitRun === undefined || (run.createdAt ?? '') > (hitRun.createdAt ?? '')) {
1126
+ hitRun = run;
1127
+ hitOrder = order;
1128
+ }
1129
+ }
1130
+ if (hitRun === undefined || hitOrder === undefined)
1131
+ return { ok: false, reason: `未找到进行中的工单 ${key}` };
1132
+ if (body === '')
1133
+ return { ok: true, runId: hitRun.id, orderId: key };
1134
+ hitOrder.reportOutput = body;
1135
+ hitOrder.reportAt = new Date().toISOString();
1136
+ if (typeof this.store?.saveRunSnapshot === 'function') {
1137
+ try {
1138
+ this.store.saveRunSnapshot(hitRun);
1139
+ }
1140
+ catch (error) {
1141
+ this.logger?.warn?.(`dsh-agent-foreman: 上报快照写入失败:${String(error?.message ?? error)}`);
1142
+ }
1143
+ }
1144
+ return { ok: true, runId: hitRun.id, orderId: key };
1145
+ }
1146
+ catch (error) {
1147
+ return { ok: false, reason: String(error?.message ?? error) };
1148
+ }
1149
+ }
1150
+
1151
+ /**
1152
+ * 验收:逐单记录通过/打回,打回的按剩余返工额度重派。
1153
+ *
1154
+ * @param args.run - 运行记录。
1155
+ * @param args.verdicts - `[{ order, verdict, feedback? }]`。
1156
+ * @param args.rework - 是否允许返工(默认 true,受 `maxRework` 约束)。
1157
+ * @param args.parent - 发起方 Agent(返工时要用)。
1158
+ * @param args.signal - 取消信号。
1159
+ * @returns 更新后的运行记录。
1160
+ */
1161
+ async review({ run, verdicts, rework = true, parent, signal }) {
1162
+ if (!Array.isArray(verdicts) || verdicts.length === 0)
1163
+ throw new ForemanError('至少给一条验收结论', 'NO_VERDICTS');
1164
+ const now = new Date().toISOString();
1165
+ const redo = [];
1166
+ for (const entry of verdicts) {
1167
+ const key = typeof entry?.order === 'string' ? entry.order.trim() : '';
1168
+ const order = run.orders.find(candidate => candidate.id === key
1169
+ || candidate.title === key
1170
+ || candidate.contractorName === key && run.orders.filter(o => o.contractorName === key).length === 1);
1171
+ if (order === undefined)
1172
+ throw new ForemanError(`本轮没有工单「${key}」(工单:${run.orders.map(o => o.id).join('、')})`, 'UNKNOWN_ORDER');
1173
+ const verdict = entry.verdict === 'pass' ? 'pass' : 'fail';
1174
+ order.verdict = verdict;
1175
+ order.verdictAt = now;
1176
+ order.feedback = typeof entry.feedback === 'string' ? entry.feedback.trim() : '';
1177
+ if (verdict === 'fail' && rework && order.attempts < 1 + run.policy.maxRework)
1178
+ redo.push(order);
1179
+ }
1180
+ if (redo.length > 0) {
1181
+ // 返工只重跑明确打回的那几张,但它们仍然受依赖约束:上游没 done
1182
+ // 就重跑等于用一份不成立的输入去验收,所以直接拒绝,而不是绕过。
1183
+ const done = new Set(run.orders.filter(order => order.status === 'done').map(order => order.id));
1184
+ for (const order of redo) {
1185
+ const missing = order.dependsOn.filter(depId => !done.has(depId));
1186
+ if (missing.length > 0)
1187
+ throw new ForemanError(`工单「${order.title}」不能返工:依赖的`
1188
+ + `${missing.map(depId => `「${depId}」`).join('、')}还没有完成`
1189
+ + `(当前状态:${missing.map((depId) => {
1190
+ const dep = run.orders.find(candidate => candidate.id === depId);
1191
+ return `${depId}=${dep?.status ?? '不存在'}`;
1192
+ }).join('、')})`, 'DEPENDENCY_NOT_DONE');
1193
+ }
1194
+ run.status = 'rework';
1195
+ for (const order of redo) {
1196
+ order.attempts += 1;
1197
+ order.status = 'queued';
1198
+ order.verdict = undefined;
1199
+ order.error = undefined;
1200
+ }
1201
+ for (const order of redo)
1202
+ await this.#runOrder({ order, parent, policy: run.policy, signal });
1203
+ run.status = 'waiting-review';
1204
+ }
1205
+ else if (run.orders.every(order => order.verdict !== undefined)) {
1206
+ // 每条都签过了:全通过才叫验收完成,否则是带着打回结论结案。
1207
+ run.status = run.orders.every(order => order.verdict === 'pass') ? 'accepted' : 'closed';
1208
+ }
1209
+ run.updatedAt = now;
1210
+ // 验收结论也要落到快照里:重启后这份看板仍然能看出"谁被打回了"。
1211
+ this.persistRun(run);
1212
+ this.store.recordRun(this.#toLedger(run));
1213
+ return run;
1214
+ }
1215
+
1216
+ /** 台账行(不存正文,避免台账随产物膨胀)。 */
1217
+ #toLedger(run) {
1218
+ return {
1219
+ id: run.id,
1220
+ at: run.createdAt,
1221
+ sessionId: run.sessionId,
1222
+ goal: run.goal,
1223
+ status: run.status,
1224
+ orders: run.orders.map(order => ({
1225
+ id: order.id,
1226
+ title: order.title,
1227
+ contractor: order.contractorName ?? '',
1228
+ route: order.route ?? '',
1229
+ // 真实路由与一致性结论也进台账:事后排查"它到底跑在哪个模型上"
1230
+ // 只能靠这一行,看板正文会随会话结束消失。
1231
+ actualRoute: order.actualRoute ?? '',
1232
+ routeMismatch: order.routeMismatch === true,
1233
+ status: order.status,
1234
+ stopReason: order.stopReason ?? '',
1235
+ durationMs: order.durationMs ?? 0,
1236
+ attempts: order.attempts,
1237
+ chars: order.chars ?? 0,
1238
+ verdict: order.verdict ?? '',
1239
+ error: order.error ?? '',
1240
+ teamMode: order.teamNote ?? '',
1241
+ })),
1242
+ };
1243
+ }
1244
+
1245
+ /**
1246
+ * 看板的文本渲染。既要给父模型读(它据此决定验收),也要在聊天卡片里立得住。
1247
+ *
1248
+ * @param run - 运行记录。
1249
+ * @param args.withOutput - 是否附上每单的产物正文。
1250
+ * @returns 多行文本。
1251
+ */
1252
+ renderBoard(run, { withOutput = false } = {}) {
1253
+ const done = run.orders.filter(order => order.status === 'done').length;
1254
+ // 跳过也是"没交活":父模型要能一眼看出下游是被挡住的,不是自己失败的。
1255
+ const failed = run.orders.filter(order => order.status === 'failed' || order.status === 'timeout' || order.status === 'skipped').length;
1256
+ const passed = run.orders.filter(order => order.verdict === 'pass').length;
1257
+ const failedReview = run.orders.filter(order => order.verdict === 'fail').length;
1258
+ const lines = [
1259
+ `包工头 · ${run.id} · ${run.orders.length} 张工单 · 交付 ${done} / 未成 ${failed}`,
1260
+ run.goal !== '' ? `目标:${run.goal}` : '',
1261
+ '─'.repeat(48),
1262
+ ].filter(line => line !== '');
1263
+ for (const order of run.orders) {
1264
+ const mark = order.verdict === 'pass' ? '✔' : order.verdict === 'fail' ? '↺' : STATUS_MARK[order.status] ?? '?';
1265
+ const seconds = order.durationMs === undefined ? '' : `${(order.durationMs / 1000).toFixed(1)}s`;
1266
+ const verdict = order.verdict === 'pass' ? ' 已验收' : order.verdict === 'fail' ? ' 打回' : '';
1267
+ const attempt = order.attempts > 1 ? ` 第${order.attempts}轮` : '';
1268
+ lines.push(`${mark} ${order.id} ${order.title} · ${order.contractorName ?? '未分配'} · ${order.route ?? ''} · ${seconds}${attempt}${verdict}`);
1269
+ if (order.teamNote !== undefined && order.teamNote !== '')
1270
+ lines.push(` ⇢ ${order.teamNote}`);
1271
+ if (order.error !== undefined && order.error !== '')
1272
+ lines.push(` ✖ ${order.error}`);
1273
+ if (order.status === 'done' && order.text !== undefined && order.text !== '') {
1274
+ if (withOutput) {
1275
+ lines.push(' ┌ 交付');
1276
+ for (const line of order.text.split('\n'))
1277
+ lines.push(` │ ${line}`);
1278
+ lines.push(' └');
1279
+ }
1280
+ else {
1281
+ const first = order.text.split('\n').find(line => line.trim() !== '') ?? '';
1282
+ lines.push(` ${first.slice(0, 100)}${first.length > 100 ? '…' : ''}`);
1283
+ }
1284
+ }
1285
+ }
1286
+ lines.push('─'.repeat(48));
1287
+ lines.push(`验收:共 ${run.orders.length} 单,通过 ${passed},打回 ${failedReview};未验收的还会留在看板上。`);
1288
+ if (run.status !== 'accepted')
1289
+ lines.push(`调用 foreman_review({ runId: "${run.id}", verdicts: [{ order: "<工单id>", verdict: "pass" | "fail", feedback: "打回理由" }] }) 逐个签收。`);
1290
+ return lines.join('\n');
1291
+ }
1292
+ }