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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jbc-dssi",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "type": "module",
5
5
  "description": "鲸编程 - 不只是手机遥控操作电脑,而是将电脑级的Agent能力装进手机",
6
6
  "keywords": [
@@ -25,8 +25,36 @@ import { buildManagerPrompt, buildMemberPrompt } from './prompt.js';
25
25
 
26
26
  /** 房间日志最多保留条数(超出丢最旧,防无限膨胀)。 */
27
27
  const ROOM_LOG_MAX = 60;
28
- /** 单个成员一轮回复的等待上限。 */
29
- const TURN_TIMEOUT_MS = 240_000;
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, missed?:boolean,
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 timeoutTimer = null;
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 (timeoutTimer) clearTimeout(timeoutTimer);
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
- if (followClosed || settled) break;
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
- settle({ failed: String(data.reason?.message ?? kind) });
317
- } else {
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
- * 本轮步数:取 turn/end 指向的那一轮(拿不到就退回最后见过的一轮)。
320
- * 0 = 没统计到(老版本插件 / 事件里没带 step)→ 手机侧整段不显示。
391
+ * 预算耗尽后迟到的真实结果 → 作为**同一轮的补发行**落库(回填,不丢弃)。
392
+ * 这是"不用 240 秒误判长任务"的最后一环。
321
393
  */
322
- const endTurn = typeof data.turn === 'number' ? data.turn : lastTurn;
323
- const steps = turnStepNums.get(endTurn)?.size ?? 0;
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
- timeoutTimer = setTimeout(() => {
369
- if (!settled) settle({ missed: true });
370
- }, TURN_TIMEOUT_MS);
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
- function emitReplyLine(group, round, member, result) {
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, missed: memberResult.missed === true });
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.missed ? '(未回应)' : '';
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
  })