jbc-dssi 1.1.5 → 1.1.7
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/package.json
CHANGED
|
@@ -25,8 +25,36 @@ import { buildManagerPrompt, buildMemberPrompt } from './prompt.js';
|
|
|
25
25
|
|
|
26
26
|
/** 房间日志最多保留条数(超出丢最旧,防无限膨胀)。 */
|
|
27
27
|
const ROOM_LOG_MAX = 60;
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 群任务等待窗口(2026-11-01 治本:把"240 秒硬超时"改成三段式)。
|
|
31
|
+
*
|
|
32
|
+
* 旧行为:prompt 后 240s 没等到 turn/end 就 settle({missed}),行落成「[未回应]」。
|
|
33
|
+
* 可构建/发布/上传这类长任务普遍超过 4 分钟:成员仍在执行、稍后确有回复,却已被
|
|
34
|
+
* 判"未回应",而且结果被丢弃 —— 这就是"未回应总伴随活干完了"的根因。
|
|
35
|
+
*
|
|
36
|
+
* 新口径:
|
|
37
|
+
* ① TURN_IDLE_TIMEOUT_MS 降级成**静默看门狗**:只观测、只记日志,绝不结算;
|
|
38
|
+
* ② 总预算按任务类型给:普通 10 分钟、长任务(发版/构建/发布/上传…)30 分钟;
|
|
39
|
+
* ③ 预算耗尽 ≠ 失败:落中性「(仍在执行,结果稍后补上)」占位;
|
|
40
|
+
* ④ 占位后继续**安静跟随**该会话(不广播,避免串到下一成员的流式块),
|
|
41
|
+
* turn/end 一到就把真实正文/用量/步数作为**同一轮的补发行**落库(回填)。
|
|
42
|
+
*/
|
|
43
|
+
const TURN_IDLE_TIMEOUT_MS = 240_000;
|
|
44
|
+
const TURN_BUDGET_DEFAULT_MS = 10 * 60_000;
|
|
45
|
+
const TURN_BUDGET_LONG_MS = 30 * 60_000;
|
|
46
|
+
/** 预算耗尽后还愿意等多久的补发窗口(超过就放弃回填,只保留中性占位)。 */
|
|
47
|
+
const LATE_WATCH_MAX_MS = 2 * 60 * 60_000;
|
|
48
|
+
/** 长任务识别:文案里出现这些词就给长预算(纯文本判定,不做语义猜测)。 */
|
|
49
|
+
const LONG_TASK_RE = /发版|发布|构建|打包|上传|部署|安装包|APK|apk|release|deploy|build|publish|npm|Gitee|gitee/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 按任务文案挑等待预算:长任务 30 分钟,其余 10 分钟。
|
|
53
|
+
* 纯函数(不依赖运行时),单测直接钉边界。
|
|
54
|
+
*/
|
|
55
|
+
export function taskBudgetMs(taskText, { defaultMs = TURN_BUDGET_DEFAULT_MS, longMs = TURN_BUDGET_LONG_MS } = {}) {
|
|
56
|
+
return LONG_TASK_RE.test(String(taskText ?? '')) ? longMs : defaultMs;
|
|
57
|
+
}
|
|
30
58
|
/**
|
|
31
59
|
* 轮次上限(最高红线,用户定稿):
|
|
32
60
|
* 「分发任务 → 接收反馈」算 1 轮;1~4 轮内可提前结束;第 5 轮收齐反馈后必须强制汇报,
|
|
@@ -126,12 +154,24 @@ function aggregateStepUsages(stepUsages) {
|
|
|
126
154
|
* @param {Function} opts.save 持久化(串行写)
|
|
127
155
|
* @param {Function} opts.broadcast 群事件广播
|
|
128
156
|
*/
|
|
129
|
-
export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }) {
|
|
157
|
+
export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast, timings = {} }) {
|
|
130
158
|
/** 当前活跃任务(同一时刻只跑一个群的一轮,防止消息乱序)。 */
|
|
131
159
|
let activeRound = null;
|
|
132
160
|
|
|
133
161
|
const now = () => Date.now();
|
|
134
162
|
|
|
163
|
+
/** 等待窗口参数:生产用默认值;测试注入小值(不新增配置项,也不改协议)。 */
|
|
164
|
+
const timing = {
|
|
165
|
+
idleMs: TURN_IDLE_TIMEOUT_MS,
|
|
166
|
+
defaultBudgetMs: TURN_BUDGET_DEFAULT_MS,
|
|
167
|
+
longBudgetMs: TURN_BUDGET_LONG_MS,
|
|
168
|
+
lateMaxMs: LATE_WATCH_MAX_MS,
|
|
169
|
+
...timings,
|
|
170
|
+
};
|
|
171
|
+
/** 按任务文案选预算(发版/构建类 → 长窗口)。 */
|
|
172
|
+
const budgetFor = (taskText) =>
|
|
173
|
+
taskBudgetMs(taskText, { defaultMs: timing.defaultBudgetMs, longMs: timing.longBudgetMs });
|
|
174
|
+
|
|
135
175
|
/**
|
|
136
176
|
* 追加一行到房间日志。
|
|
137
177
|
*
|
|
@@ -177,16 +217,23 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
|
|
|
177
217
|
/**
|
|
178
218
|
* 投递一轮给一个成员,等回复完成后 settle。
|
|
179
219
|
*
|
|
180
|
-
* @returns {Promise<{text?:string, failed?:string,
|
|
220
|
+
* @returns {Promise<{text?:string, failed?:string, stillRunning?:boolean,
|
|
181
221
|
* usage?:object|null, steps?:number, stepUsages?:Array<object>}>}
|
|
222
|
+
* `stillRunning` = 总预算耗尽(任务可能还在执行,结果稍后以补发行回填)。
|
|
182
223
|
* `stepUsages` = 本轮每一步的用量(每一步一条,按到达顺序);它和 `usage`
|
|
183
224
|
* (各步求和)一起进房间日志,供读侧还原与兼容老客户端。
|
|
184
225
|
*/
|
|
185
|
-
function deliver(group, member, prompt, attachments = {}) {
|
|
226
|
+
function deliver(group, member, prompt, attachments = {}, opts = {}) {
|
|
227
|
+
const budgetMs = typeof opts.budgetMs === 'number' ? opts.budgetMs : timing.defaultBudgetMs;
|
|
228
|
+
const roundNo = opts.round?.rounds ?? 0;
|
|
186
229
|
return new Promise((resolve) => {
|
|
187
230
|
let settled = false;
|
|
231
|
+
let stillRunning = false;
|
|
232
|
+
let lateWait = false;
|
|
188
233
|
let followClosed = false;
|
|
189
|
-
let
|
|
234
|
+
let idleTimer = null;
|
|
235
|
+
let budgetTimer = null;
|
|
236
|
+
let lateTimer = null;
|
|
190
237
|
/*
|
|
191
238
|
* ★ 结算判据:等「整个 turn 结束」,不是「第一条 assistant/message」。
|
|
192
239
|
*
|
|
@@ -231,12 +278,27 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
|
|
|
231
278
|
const turnStepNums = new Map();
|
|
232
279
|
let lastTurn = -1;
|
|
233
280
|
|
|
281
|
+
const clearIdle = () => {
|
|
282
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
283
|
+
};
|
|
234
284
|
const settle = (result) => {
|
|
235
285
|
if (settled) return;
|
|
236
286
|
settled = true;
|
|
237
|
-
if (
|
|
287
|
+
if (budgetTimer) { clearTimeout(budgetTimer); budgetTimer = null; }
|
|
288
|
+
clearIdle();
|
|
238
289
|
resolve(result);
|
|
239
290
|
};
|
|
291
|
+
/*
|
|
292
|
+
* 静默看门狗:任意帧到达就重置;静默只记日志,**绝不结算**。
|
|
293
|
+
* (长命令执行期间可能长时间没有事件,那不等于失联。)
|
|
294
|
+
*/
|
|
295
|
+
const noteActivity = () => {
|
|
296
|
+
if (settled) return;
|
|
297
|
+
clearIdle();
|
|
298
|
+
idleTimer = setTimeout(() => {
|
|
299
|
+
log?.(`[groups] ${member.name} 静默 ${timing.idleMs}ms,任务可能仍在执行(不结算、不判未回应)`);
|
|
300
|
+
}, timing.idleMs);
|
|
301
|
+
};
|
|
240
302
|
let sessionId = null;
|
|
241
303
|
(async () => {
|
|
242
304
|
try {
|
|
@@ -263,7 +325,9 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
|
|
|
263
325
|
* 于是上一位的尾巴被画进了下一位的块里,表现为
|
|
264
326
|
* 「上一成员没有输出,下一成员的话就来了」(用户实测原话)。
|
|
265
327
|
*/
|
|
266
|
-
|
|
328
|
+
// 成功/明确失败才停;预算耗尽后进入"补发观察",继续安静跟随却不再广播
|
|
329
|
+
if (followClosed || (settled && !lateWait)) break;
|
|
330
|
+
noteActivity();
|
|
267
331
|
// 群消息流式增量:与智能体单聊同一推送机制 —— assistant-stream 帧原样
|
|
268
332
|
// 广播给手机(room_stream),手机侧用单聊同款 reduceLive 边收边画;
|
|
269
333
|
// 完整文本仍由 assistant/message → settle → room_line 落盘(下方处理)。
|
|
@@ -312,17 +376,27 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
|
|
|
312
376
|
}
|
|
313
377
|
} else if (event?.type === 'turn/end') {
|
|
314
378
|
const kind = data.reason?.kind;
|
|
379
|
+
/*
|
|
380
|
+
* 本轮步数:取 turn/end 指向的那一轮(拿不到就退回最后见过的一轮)。
|
|
381
|
+
* 0 = 没统计到(老版本插件 / 事件里没带 step)→ 手机侧整段不显示。
|
|
382
|
+
*/
|
|
383
|
+
const endTurn = typeof data.turn === 'number' ? data.turn : lastTurn;
|
|
384
|
+
const steps = turnStepNums.get(endTurn)?.size ?? 0;
|
|
315
385
|
if (kind && kind !== 'completed') {
|
|
316
|
-
|
|
317
|
-
|
|
386
|
+
const failed = String(data.reason?.message ?? kind);
|
|
387
|
+
if (settled && lateWait) emitLateFailure(group, member, failed, roundNo);
|
|
388
|
+
else settle({ failed });
|
|
389
|
+
} else if (settled && lateWait) {
|
|
318
390
|
/*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
391
|
+
* 预算耗尽后迟到的真实结果 → 作为**同一轮的补发行**落库(回填,不丢弃)。
|
|
392
|
+
* 这是"不用 240 秒误判长任务"的最后一环。
|
|
321
393
|
*/
|
|
322
|
-
|
|
323
|
-
|
|
394
|
+
emitLateReply(group, member, { text: lastText, usage: turnUsage, steps, stepUsages }, roundNo);
|
|
395
|
+
followClosed = true;
|
|
396
|
+
} else {
|
|
324
397
|
settle({ text: lastText, usage: turnUsage, steps, stepUsages });
|
|
325
398
|
}
|
|
399
|
+
if (lateTimer) { clearTimeout(lateTimer); lateTimer = null; }
|
|
326
400
|
ac.abort();
|
|
327
401
|
} else if (event?.type === 'turn/error' || event?.type === 'turn/failed') {
|
|
328
402
|
settle({ failed: String(event?.data?.error?.message ?? '会话失败') });
|
|
@@ -365,9 +439,23 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
|
|
|
365
439
|
);
|
|
366
440
|
// 请求已进队列:此后到达的事件才属于本轮(之前的都是历史回放)。
|
|
367
441
|
prompted = true;
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
442
|
+
noteActivity();
|
|
443
|
+
/*
|
|
444
|
+
* 总预算(任务类型决定 10 / 30 分钟)。耗尽 ≠ 失败:
|
|
445
|
+
* 落中性「(仍在执行,结果稍后补上)」占位,并继续安静跟随等真实结果回填。
|
|
446
|
+
*/
|
|
447
|
+
budgetTimer = setTimeout(() => {
|
|
448
|
+
if (settled) return;
|
|
449
|
+
lateWait = true;
|
|
450
|
+
stillRunning = true;
|
|
451
|
+
settle({ stillRunning: true });
|
|
452
|
+
lateTimer = setTimeout(() => {
|
|
453
|
+
lateWait = false;
|
|
454
|
+
followClosed = true;
|
|
455
|
+
ac.abort();
|
|
456
|
+
log?.(`[groups] ${member.name} 补发窗口 ${timing.lateMaxMs}ms 内仍未结束,放弃回填(中性占位保留)`);
|
|
457
|
+
}, timing.lateMaxMs);
|
|
458
|
+
}, budgetMs);
|
|
371
459
|
} catch (err) {
|
|
372
460
|
if (!settled) {
|
|
373
461
|
followClosed = true;
|
|
@@ -395,8 +483,41 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
|
|
|
395
483
|
* 新手机的 GroupStore 优先用 `stepUsages` 求和 —— 刷新/重进后与实时同源。
|
|
396
484
|
* 旧行没有 `stepUsages`:读侧原样显示历史值,不补 0、不重算、不报错(历史不救)。
|
|
397
485
|
*/
|
|
398
|
-
|
|
486
|
+
/** 预算耗尽后迟到的真实回复:作为**同一轮的补发行**追加(不改已有行、不丢结果)。 */
|
|
487
|
+
function emitLateReply(group, member, result, roundNo) {
|
|
399
488
|
const text = String(result.text ?? '').trim() || '[未回应]';
|
|
489
|
+
emitLine(group.id, {
|
|
490
|
+
at: now(),
|
|
491
|
+
role: 'member',
|
|
492
|
+
memberId: member.id,
|
|
493
|
+
name: member.name,
|
|
494
|
+
text,
|
|
495
|
+
usage: result.usage ?? null,
|
|
496
|
+
steps: result.steps ?? 0,
|
|
497
|
+
stepUsages: Array.isArray(result.stepUsages) && result.stepUsages.length > 0 ? result.stepUsages : undefined,
|
|
498
|
+
late: true,
|
|
499
|
+
round: roundNo,
|
|
500
|
+
});
|
|
501
|
+
log?.(`[groups] ${member.name} 的迟到回复已补发(round=${roundNo})`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** 预算耗尽后迟到的明确失败:只有明确失败才报错(中性"仍在执行"不报失败)。 */
|
|
505
|
+
function emitLateFailure(group, member, failed, roundNo) {
|
|
506
|
+
emitLine(group.id, {
|
|
507
|
+
at: now(), role: 'system', name: member.name,
|
|
508
|
+
text: `${member.name} 回应失败:${failed}`,
|
|
509
|
+
late: true,
|
|
510
|
+
round: roundNo,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function emitReplyLine(group, round, member, result) {
|
|
515
|
+
const trimmed = String(result.text ?? '').trim();
|
|
516
|
+
/*
|
|
517
|
+
* 状态口径分离(2026-11-01):预算耗尽 → 中性「(仍在执行…)」,**不是**未回应;
|
|
518
|
+
* 「[未回应]」只留给"正常结束但整轮没有正文"(纯工具调用)这种诚实兜底。
|
|
519
|
+
*/
|
|
520
|
+
const text = trimmed || (result.stillRunning ? '(仍在执行,结果稍后补上)' : '[未回应]');
|
|
400
521
|
emitLine(group.id, {
|
|
401
522
|
at: now(),
|
|
402
523
|
role: 'member',
|
|
@@ -409,6 +530,8 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
|
|
|
409
530
|
stepUsages: Array.isArray(result.stepUsages) && result.stepUsages.length > 0
|
|
410
531
|
? result.stepUsages
|
|
411
532
|
: undefined,
|
|
533
|
+
// 中性"仍在执行"标记(老 app 忽略未知字段;新读侧/排查可据此区分)
|
|
534
|
+
stillRunning: result.stillRunning === true ? true : undefined,
|
|
412
535
|
round: round?.rounds ?? 0,
|
|
413
536
|
});
|
|
414
537
|
round.totalTurns += 1;
|
|
@@ -429,7 +552,7 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
|
|
|
429
552
|
task: text,
|
|
430
553
|
mode: 'user-direct',
|
|
431
554
|
});
|
|
432
|
-
const result = await deliver(group, member, prompt, attachments);
|
|
555
|
+
const result = await deliver(group, member, prompt, attachments, { budgetMs: budgetFor(text), round });
|
|
433
556
|
if (result.failed) {
|
|
434
557
|
emitLine(group.id, {
|
|
435
558
|
at: now(), role: 'system', name: member.name,
|
|
@@ -468,7 +591,7 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
|
|
|
468
591
|
});
|
|
469
592
|
// 用户附件只在第一轮(计划阶段)带给主管;后续复查阶段不再重复投递。
|
|
470
593
|
const managerAttachments = roundIndex === 0 && feedbacks.length === 0 ? attachments : {};
|
|
471
|
-
const result = await deliver(group, manager, prompt, managerAttachments);
|
|
594
|
+
const result = await deliver(group, manager, prompt, managerAttachments, { round });
|
|
472
595
|
if (result.failed) {
|
|
473
596
|
emitLine(group.id, {
|
|
474
597
|
at: now(), role: 'system', name: manager.name,
|
|
@@ -542,7 +665,10 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
|
|
|
542
665
|
task: managerText,
|
|
543
666
|
mode: 'manager-task',
|
|
544
667
|
});
|
|
545
|
-
const memberResult = await deliver(group, target, memberPrompt, attachments
|
|
668
|
+
const memberResult = await deliver(group, target, memberPrompt, attachments, {
|
|
669
|
+
budgetMs: budgetFor(managerText),
|
|
670
|
+
round,
|
|
671
|
+
});
|
|
546
672
|
if (memberResult.failed) {
|
|
547
673
|
emitLine(group.id, {
|
|
548
674
|
at: now(), role: 'system', name: target.name,
|
|
@@ -554,7 +680,7 @@ export function createRoundEngine({ ctx, state, dssAgent, log, save, broadcast }
|
|
|
554
680
|
continue;
|
|
555
681
|
}
|
|
556
682
|
const text = emitReplyLine(group, round, target, memberResult);
|
|
557
|
-
feedbacks.push({ name: target.name, text,
|
|
683
|
+
feedbacks.push({ name: target.name, text, stillRunning: memberResult.stillRunning === true });
|
|
558
684
|
}
|
|
559
685
|
// 第 MAX_ROUNDS 轮反馈收齐 → 下一句主管只能汇报
|
|
560
686
|
forcedReport = roundIndex >= MAX_ROUNDS;
|
|
@@ -64,7 +64,7 @@ export function buildManagerPrompt({
|
|
|
64
64
|
const feedbackText = (feedbacks ?? []).length
|
|
65
65
|
? (feedbacks ?? [])
|
|
66
66
|
.map((f) => {
|
|
67
|
-
const tag = f.failed ? '(回应失败)' : f.
|
|
67
|
+
const tag = f.failed ? '(回应失败)' : f.stillRunning ? '(仍在执行)' : '';
|
|
68
68
|
const text = String(f.text ?? '').replace(/\s+/g, ' ').slice(0, LINE_TEXT_MAX);
|
|
69
69
|
return `- ${f.name}${tag}:${text}`;
|
|
70
70
|
})
|