@xgjktech/xg_cwork_im 1.19.35 → 1.19.36

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.
@@ -1,26 +1,28 @@
1
1
  import { getOrCreateShared, getSharedMap } from "../plugin/process-shared-store.js";
2
2
  import { turnOnAllChildrenDone } from "./turn-end-controller.js";
3
- /** 模块实例 ID:用于确认插件是否被重复加载(若日志出现两个不同 id,则存在双实例,spawn/end 可能被不同实例处理)。 */
3
+ /** 模块实例 ID:用于确认插件是否被重复加载(若日志出现两个不同 id,则存在双实例)。 */
4
4
  const MODULE_INSTANCE_ID = `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
5
5
  console.log(`[subagent-monitor] module loaded instance=${MODULE_INSTANCE_ID} pid=${process.pid} title=${(process.title || "(none)").trim()}`);
6
- // 进程级共享:跨模块重载后监控不中断——各代模块读写同一份 maps/轮询/api 候选。
7
- const childToRound = getSharedMap("subagent-child-to-round");
8
- const roundMonitors = getSharedMap("subagent-round-monitors");
9
- const deferredFinished = getSharedMap("subagent-deferred-finished");
6
+ // 进程级共享:跨模块重载后状态不中断。
7
+ const targets = getSharedMap("subagent-targets");
8
+ const groupAnchors = getSharedMap("subagent-group-anchors");
10
9
  const roundAdmissionTombstones = getSharedMap("subagent-round-admission-tombstones");
11
10
  /** 记录被 `/stop` 中断的 subagent run(按 childSessionKey 索引,存被中断 runId 与过期时间)。 */
12
11
  const childTombstones = getSharedMap("subagent-child-tombstones");
13
12
  const POLL_INTERVAL_MS = 15_000;
14
13
  /**
15
14
  * apiCandidates 保留上限:OpenClaw 会对同一模块反复重注册(多代 registry 并存且从不触发
16
- * cleanup,见日志 `registerSeq` 递增而 `cleanup` 为 0)。每代 register 都会 push 一个新 api 进
17
- * 进程级 candidates,若不停老代则数组无限增长、引用无法回收。该数组仅作为「当前 runtime 探测
18
- * 不可用时的回退池」(resolveUsablePluginApi),而单进程内 register 是按时间顺序的短爆发(通常
19
- * 2~4 次),功能上只需最近几代即可,故截断更早的候选。
15
+ * cleanup)。数组仅作为「当前 runtime 探测不可用时的回退池」(resolveUsablePluginApi),
16
+ * 单进程内 register 是按时间顺序的短爆发,功能上只需最近几代即可,故截断更早候选。
20
17
  */
21
18
  const MAX_API_CANDIDATES = 4;
22
19
  const ROUND_TOMBSTONE_TTL_MS = 30_000;
23
20
  const CHILD_TOMBSTONE_TTL_MS = 5 * 60_000;
21
+ const GROUP_ANCHOR_TTL_MS = 24 * 60 * 60_000;
22
+ /** 仍在跑的目标最长存活:仅作泄漏兜底;正常由 ended 事件 / 轮询对账回收。 */
23
+ const TARGET_TTL_MS = 60 * 60_000;
24
+ /** 已完成目标的保留窗口:用于 /stop 快照与迟到事件幂等去重,随后回收。 */
25
+ const COMPLETED_TARGET_TTL_MS = 5 * 60_000;
24
26
  const MAX_INCR_MSGS = 5;
25
27
  const MSG_TRUNCATE_LEN = 40;
26
28
  /** 单次 tick 用 openclaw `agent.wait` 对账 subagent run 状态的探测超时。 */
@@ -111,7 +113,15 @@ async function resolveUsablePluginApi() {
111
113
  export function getSubagentMonitorInstanceId() {
112
114
  return MODULE_INSTANCE_ID;
113
115
  }
114
- function pruneTombstones(now = Date.now()) {
116
+ /** 记录该会话最近一次消息锚点,供拿不到 `requester.messageId` 时兜底。 */
117
+ export function noteSubagentGroupAnchor(groupId, anchor) {
118
+ const gid = groupId?.trim();
119
+ const a = anchor?.trim();
120
+ if (!gid || !a)
121
+ return;
122
+ groupAnchors.set(gid, { anchor: a, expiresAt: Date.now() + GROUP_ANCHOR_TTL_MS });
123
+ }
124
+ function prune(now = Date.now()) {
115
125
  for (const [key, expiresAt] of roundAdmissionTombstones) {
116
126
  if (expiresAt <= now)
117
127
  roundAdmissionTombstones.delete(key);
@@ -120,270 +130,304 @@ function pruneTombstones(now = Date.now()) {
120
130
  if (tombstone.expiresAt <= now)
121
131
  childTombstones.delete(key);
122
132
  }
133
+ for (const [key, entry] of groupAnchors) {
134
+ if (entry.expiresAt <= now)
135
+ groupAnchors.delete(key);
136
+ }
137
+ for (const [key, target] of Array.from(targets)) {
138
+ if (target.expiresAt <= now) {
139
+ console.warn(`[subagent-monitor] target TTL expired instance=${MODULE_INSTANCE_ID} ` +
140
+ `childSessionKey=${target.childSessionKey} runId=${target.runId} completed=${target.completed} completedSent=${target.completedStatSent === true}`);
141
+ targets.delete(key);
142
+ continue;
143
+ }
144
+ // 已完成且终态快照已推送的目标,保留一小段窗口(/stop 快照 + 迟到事件去重)后回收;
145
+ // 已完成但快照尚未推送成功的目标不得回收,留给 tick 重试。
146
+ if (target.completed &&
147
+ target.completedStatSent &&
148
+ (target.completedAt ?? 0) + COMPLETED_TARGET_TTL_MS <= now) {
149
+ targets.delete(key);
150
+ }
151
+ }
152
+ }
153
+ /** 是否仍有待处理目标:仍在跑,或已完成但终态快照尚未成功推送。 */
154
+ function hasPendingTarget() {
155
+ for (const target of targets.values()) {
156
+ if (!target.completed || !target.completedStatSent)
157
+ return true;
158
+ }
159
+ return false;
123
160
  }
124
161
  function stopTickerIfIdle() {
125
- if (roundMonitors.size === 0 && m.ticker) {
162
+ if (!hasPendingTarget() && m.ticker) {
126
163
  clearInterval(m.ticker);
127
164
  m.ticker = null;
128
165
  }
129
166
  }
130
- function removeMonitor(monitor, reason) {
131
- console.warn(`[subagent-monitor] removeMonitor instance=${MODULE_INSTANCE_ID} reason=${reason} roundKey=${monitor.roundKey} gen=${monitor.generation} ` +
132
- `subagents=${Array.from(monitor.subagents.values()).map((r) => r.runId).join(",") || "(none)"}`);
133
- if (roundMonitors.get(monitor.roundKey)?.generation === monitor.generation) {
134
- roundMonitors.delete(monitor.roundKey);
135
- }
136
- if (deferredFinished.get(monitor.roundKey)?.generation === monitor.generation) {
137
- deferredFinished.delete(monitor.roundKey);
138
- }
139
- for (const record of monitor.subagents.values()) {
140
- const indexed = childToRound.get(record.childSessionKey);
141
- if (indexed?.roundKey === monitor.roundKey && indexed.generation === monitor.generation) {
142
- childToRound.delete(record.childSessionKey);
143
- }
144
- }
145
- stopTickerIfIdle();
167
+ function ensureTickerRunning() {
168
+ if (m.ticker)
169
+ return;
170
+ void tick();
171
+ m.ticker = setInterval(() => void tick(), POLL_INTERVAL_MS);
172
+ m.ticker.unref?.();
146
173
  }
147
- export function startMonitor(params) {
148
- pruneTombstones();
149
- if (roundAdmissionTombstones.has(params.roundKey)) {
150
- console.warn(`[subagent-monitor] startMonitor rejected instance=${MODULE_INSTANCE_ID} reason=roundAdmissionTombstone roundKey=${params.roundKey} runId=${params.spawn.runId}`);
174
+ /**
175
+ * 登记一个子任务的发送目标并立即推送 running。
176
+ *
177
+ * 不要求存在活跃轮次:`roundKey`/`generation` 缺省时仅做发送与对账,不参与整轮计数。
178
+ */
179
+ export function registerSubagent(params) {
180
+ prune();
181
+ if (!params.childSessionKey || !params.runId) {
182
+ console.warn(`[subagent-monitor] register rejected instance=${MODULE_INSTANCE_ID} reason=missingKey`);
151
183
  return false;
152
184
  }
153
- let monitor = roundMonitors.get(params.roundKey);
154
- if (monitor && monitor.generation !== params.generation) {
155
- removeMonitor(monitor, "generation-change");
156
- monitor = undefined;
157
- }
158
- if (!monitor) {
159
- monitor = {
160
- roundKey: params.roundKey,
161
- generation: params.generation,
162
- groupId: params.groupId,
163
- agentId: params.agentId,
164
- streamClient: params.streamClient,
165
- replyToMsgId: params.replyToMsgId,
166
- subagents: new Map(),
167
- };
168
- roundMonitors.set(params.roundKey, monitor);
169
- }
170
- if (monitor.subagents.has(params.spawn.runId)) {
171
- console.warn(`[subagent-monitor] startMonitor rejected instance=${MODULE_INSTANCE_ID} reason=duplicateRunId roundKey=${params.roundKey} runId=${params.spawn.runId}`);
185
+ if (params.roundKey && roundAdmissionTombstones.has(params.roundKey)) {
186
+ console.warn(`[subagent-monitor] register rejected instance=${MODULE_INSTANCE_ID} reason=roundAdmissionTombstone ` +
187
+ `roundKey=${params.roundKey} runId=${params.runId}`);
172
188
  return false;
173
189
  }
174
- const record = {
175
- ...params.spawn,
176
- lastMsgIndex: 0,
177
- completed: false,
190
+ const existing = targets.get(params.childSessionKey);
191
+ if (existing) {
192
+ if (existing.runId === params.runId) {
193
+ console.warn(`[subagent-monitor] register rejected instance=${MODULE_INSTANCE_ID} reason=duplicateRunId ` +
194
+ `childSessionKey=${params.childSessionKey} runId=${params.runId}`);
195
+ return false;
196
+ }
197
+ // 同 childSessionKey 的新 run(steer-restart / run 重建):覆盖旧目标。
198
+ // 旧 run 的卡片若不收尾会永久停在「进行中」,这里补发一次 completed 再移除。
199
+ console.warn(`[subagent-monitor] register replace instance=${MODULE_INSTANCE_ID} childSessionKey=${params.childSessionKey} ` +
200
+ `oldRunId=${existing.runId} newRunId=${params.runId}`);
201
+ void closeSupersededTarget(existing).catch((err) => {
202
+ console.error(`[subagent-monitor] close superseded target failed: ${String(err)}`);
203
+ });
204
+ targets.delete(params.childSessionKey);
205
+ }
206
+ const anchor = (params.replyToMsgId ?? "").trim() || groupAnchors.get(params.groupId)?.anchor || "";
207
+ if (anchor) {
208
+ noteSubagentGroupAnchor(params.groupId, anchor);
209
+ }
210
+ const target = {
211
+ childSessionKey: params.childSessionKey,
212
+ runId: params.runId,
213
+ label: params.label ?? params.runId.slice(0, 8),
214
+ agentId: params.agentId,
215
+ groupId: params.groupId,
216
+ anchorMsgId: anchor,
217
+ streamClient: params.streamClient,
178
218
  messages: [],
219
+ lastMsgIndex: 0,
179
220
  sendFrom: 0,
180
221
  reading: false,
222
+ completed: false,
223
+ roundKey: params.roundKey,
224
+ generation: params.generation,
225
+ expiresAt: Date.now() + TARGET_TTL_MS,
181
226
  };
182
- monitor.subagents.set(record.runId, record);
183
- childToRound.set(record.childSessionKey, {
184
- roundKey: monitor.roundKey,
185
- generation: monitor.generation,
186
- runId: record.runId,
187
- });
188
- console.warn(`[subagent-monitor] startMonitor ok instance=${MODULE_INSTANCE_ID} roundKey=${monitor.roundKey} gen=${monitor.generation} ` +
189
- `childSessionKey=${record.childSessionKey} runId=${record.runId}`);
190
- sendWsStat(monitor, monitor.streamClient);
227
+ targets.set(target.childSessionKey, target);
228
+ console.log(`[subagent-monitor] register ok instance=${MODULE_INSTANCE_ID} childSessionKey=${target.childSessionKey} ` +
229
+ `runId=${target.runId} round=${target.roundKey ? `${target.roundKey}#${target.generation}` : "(none)"} ` +
230
+ `anchor=${anchor ? "yes" : "(none)"}`);
231
+ void pushSubagentStat(target);
191
232
  ensureTickerRunning();
192
233
  return true;
193
234
  }
194
235
  export function hasActiveSubagents(roundKey, generation) {
195
- const monitor = roundMonitors.get(roundKey);
196
- return Boolean(monitor &&
197
- monitor.generation === generation &&
198
- Array.from(monitor.subagents.values()).some((record) => !record.completed));
236
+ return Array.from(targets.values()).some((target) => target.roundKey === roundKey && target.generation === generation && !target.completed);
199
237
  }
200
238
  /** 子任务结束事件定位其所属轮次(供 turn-end-controller 递减活跃子任务数)。 */
201
239
  export function findChildRoundIndex(childSessionKey) {
202
- return childToRound.get(childSessionKey);
240
+ const target = targets.get(childSessionKey);
241
+ if (!target || target.roundKey === undefined || target.generation === undefined)
242
+ return undefined;
243
+ return { roundKey: target.roundKey, generation: target.generation, runId: target.runId };
203
244
  }
204
- export function deferFinished(roundKey, generation, reason) {
205
- deferredFinished.set(roundKey, { reason, generation });
245
+ /**
246
+ * 整轮收尾(dispatch wrapper finally)调用。
247
+ *
248
+ * 与旧实现不同:**不销毁仍在跑的目标**——仅解除其轮次归属(保留 groupId/锚点/client),
249
+ * 迟到完成仍能补发 completed;已完成的同类目标顺带回收。
250
+ */
251
+ export function finalizeRoundMonitoring(roundKey, generation) {
252
+ for (const [key, target] of Array.from(targets)) {
253
+ if (target.roundKey !== roundKey || target.generation !== generation)
254
+ continue;
255
+ // 仅当 completed 快照已成功推送才回收;否则保留并解除轮次归属,交给 tick 重试补发。
256
+ if (target.completed && target.completedStatSent) {
257
+ targets.delete(key);
258
+ continue;
259
+ }
260
+ target.roundKey = undefined;
261
+ target.generation = undefined;
262
+ }
206
263
  }
207
264
  export function prepareSubagentInterrupt(roundKey, generation, now = Date.now()) {
208
- pruneTombstones(now);
265
+ prune(now);
209
266
  roundAdmissionTombstones.set(roundKey, now + ROUND_TOMBSTONE_TTL_MS);
210
- const monitor = roundMonitors.get(roundKey);
211
- if (!monitor || monitor.generation !== generation)
212
- return [];
213
- const result = Array.from(monitor.subagents.values()).map((record) => {
214
- childTombstones.set(record.childSessionKey, {
215
- runId: record.runId,
267
+ const result = [];
268
+ for (const [key, target] of Array.from(targets)) {
269
+ if (target.roundKey !== roundKey || target.generation !== generation)
270
+ continue;
271
+ childTombstones.set(target.childSessionKey, {
272
+ runId: target.runId,
216
273
  expiresAt: now + CHILD_TOMBSTONE_TTL_MS,
217
274
  });
218
- return {
219
- runId: record.runId,
220
- label: record.label ?? record.runId.slice(0, 8),
221
- status: record.completed ? "completed" : "suspended",
222
- messages: record.messages,
223
- };
224
- });
225
- removeMonitor(monitor, "interrupt");
275
+ result.push({
276
+ runId: target.runId,
277
+ label: target.label,
278
+ status: target.completed ? "completed" : "suspended",
279
+ messages: target.messages,
280
+ });
281
+ targets.delete(key);
282
+ }
283
+ stopTickerIfIdle();
226
284
  return result;
227
285
  }
228
286
  export function markSubagentCompleted(childSessionKey, runId) {
229
- pruneTombstones();
287
+ prune();
230
288
  // 仅拦截被 `/stop` 中断的同一 runId 的迟到结束事件;新 run(不同 runId 复用同 key)不受影响。
231
289
  const tombstone = childTombstones.get(childSessionKey);
232
290
  if (tombstone && runId && runId === tombstone.runId)
233
291
  return;
234
- const entry = childToRound.get(childSessionKey);
235
- if (!entry) {
236
- console.debug(`[subagent-monitor] markSubagentCompleted: no round entry instance=${MODULE_INSTANCE_ID} childSessionKey=${childSessionKey}`);
292
+ const target = targets.get(childSessionKey);
293
+ if (!target) {
294
+ console.debug(`[subagent-monitor] markSubagentCompleted: no target instance=${MODULE_INSTANCE_ID} ` +
295
+ `childSessionKey=${childSessionKey} runId=${runId ?? "(none)"}`);
237
296
  return;
238
297
  }
239
- console.debug(`[subagent-monitor] markSubagentCompleted: entry found instance=${MODULE_INSTANCE_ID} childSessionKey=${childSessionKey} runId=${runId ?? "(none)"} ` +
240
- `roundKey=${entry.roundKey} gen=${entry.generation}`);
241
- const monitor = roundMonitors.get(entry.roundKey);
242
- if (!monitor || monitor.generation !== entry.generation) {
243
- console.debug(`[subagent-monitor] markSubagentCompleted: monitor mismatch childSessionKey=${childSessionKey} runId=${runId ?? "(none)"} gen=${entry.generation}`);
244
- return;
245
- }
246
- // runId 可能与 spawn 时不同(steer-restart / run 重建):优先精确匹配,回退到 childSessionKey。
247
- let record = runId ? monitor.subagents.get(runId) : undefined;
248
- if (!record) {
249
- record = Array.from(monitor.subagents.values()).find((r) => r.childSessionKey === childSessionKey);
250
- if (record && runId && record.runId !== runId) {
251
- console.debug(`[subagent-monitor] markSubagentCompleted: runId fallback childSessionKey=${childSessionKey} ` +
252
- `eventRunId=${runId} recordRunId=${record.runId}`);
253
- }
254
- }
255
- if (!record) {
256
- console.debug(`[subagent-monitor] markSubagentCompleted: no record childSessionKey=${childSessionKey} runId=${runId ?? "(none)"}`);
257
- return;
298
+ if (runId && target.runId !== runId) {
299
+ // runId 可能与 spawn 时不同(steer-restart / run 重建):回退到 childSessionKey。
300
+ console.debug(`[subagent-monitor] markSubagentCompleted: runId fallback instance=${MODULE_INSTANCE_ID} ` +
301
+ `childSessionKey=${childSessionKey} eventRunId=${runId} targetRunId=${target.runId}`);
258
302
  }
259
303
  void (async () => {
260
304
  try {
261
- await readSubagentMessages(record);
305
+ await readSubagentMessages(target);
262
306
  }
263
307
  catch (err) {
264
308
  console.error(`[subagent-monitor] completion read failed: ${String(err)}`);
265
309
  }
266
- if (record.completed)
267
- return;
268
- record.completed = true;
269
- await settleMonitorIfComplete(monitor);
310
+ await completeTarget(target);
270
311
  })();
271
312
  }
272
- async function settleMonitorIfComplete(monitor) {
273
- if (roundMonitors.get(monitor.roundKey) !== monitor)
274
- return;
275
- if (!Array.from(monitor.subagents.values()).every((record) => record.completed))
313
+ async function completeTarget(target) {
314
+ if (target.completed)
276
315
  return;
277
- const signature = statSignature(monitor);
278
- if (signature === monitor.lastPushedSignature)
279
- return;
280
- // 整轮收尾(WS FINISHED + RUN_USAGE + completeActiveRound)改由 turn-end-controller 在
281
- // 主会话出现非 yield 段终态(或静默兜底)后,由 dispatch wrapper 统一执行;
282
- // 这里只负责推送 SUBAGENT_STAT 快照,绝不自行 end/remove/complete ——
283
- // OpenClaw 7.1+/8.x 中「所有已知子任务完成」不代表整轮结束(主会话可能 resume
284
- // 继续派发新一批子任务),因此本快照允许多波次多次推送,仅对完全相同的状态去重。
316
+ target.completed = true;
317
+ target.completedAt = Date.now();
285
318
  try {
286
- await sendFinalStat(monitor, monitor.streamClient);
287
- for (const record of monitor.subagents.values()) {
288
- record.sendFrom = record.messages.length;
289
- }
290
- monitor.lastPushedSignature = signature;
291
- console.log(`[subagent-monitor] final SUBAGENT_STAT sent (settle) roundKey=${monitor.roundKey} gen=${monitor.generation} ` +
292
- `subagents=${Array.from(monitor.subagents.values()).length} — waiting turn-end for FINISHED`);
319
+ await pushCompleted(target);
293
320
  }
294
321
  catch (err) {
295
- console.error(`[subagent-monitor] final state send failed: ${String(err)}`);
322
+ console.error(`[subagent-monitor] completed SUBAGENT_STAT failed: ${String(err)}`);
296
323
  }
297
- turnOnAllChildrenDone(monitor.roundKey, monitor.generation);
324
+ maybeAllChildrenDone(target.roundKey, target.generation);
325
+ stopTickerIfIdle();
298
326
  }
299
327
  /**
300
- * 整轮收尾时(dispatch wrapper 在 turn-end-controller 放行后)清理本轮监控。
301
- * 与 removeMonitor 的 settle 路径不同:此处轮次真正结束,直接拆监控与子任务索引。
328
+ * 覆盖同 childSessionKey 的旧 run 时,先把旧 run 的卡片收尾(补发 completed),
329
+ * 否则旧 runId 的卡片会永久停在「进行中」。不做整轮 activeChildren 对账:
330
+ * 该 child 的逻辑计数由紧接着登记的新目标继续代表。
302
331
  */
303
- export function finalizeRoundMonitoring(roundKey, generation) {
304
- const monitor = roundMonitors.get(roundKey);
305
- if (!monitor || monitor.generation !== generation)
332
+ async function closeSupersededTarget(target) {
333
+ if (target.completed)
306
334
  return;
307
- removeMonitor(monitor, "round-finished");
335
+ target.completed = true;
336
+ target.completedAt = Date.now();
337
+ await pushCompleted(target);
308
338
  }
309
- async function readSubagentMessages(record) {
310
- if (!m.api || record.reading)
339
+ /** 整轮计数对账:该轮已无未完成目标时,通知 turn-end-controller。 */
340
+ function maybeAllChildrenDone(roundKey, generation) {
341
+ if (!roundKey || generation === undefined)
311
342
  return;
312
- record.reading = true;
343
+ const anyActive = Array.from(targets.values()).some((target) => target.roundKey === roundKey && target.generation === generation && !target.completed);
344
+ if (!anyActive)
345
+ turnOnAllChildrenDone(roundKey, generation);
346
+ }
347
+ async function readSubagentMessages(target) {
348
+ if (!m.api || target.reading)
349
+ return;
350
+ target.reading = true;
313
351
  try {
314
352
  let messages = [];
315
353
  try {
316
354
  const result = await m.api.runtime.subagent.getSessionMessages({
317
- sessionKey: record.childSessionKey,
355
+ sessionKey: target.childSessionKey,
318
356
  });
319
357
  messages = Array.isArray(result.messages) ? result.messages : [];
320
358
  }
321
359
  catch (err) {
322
360
  // runtime.subagent 可能只在 gateway 请求上下文内可用(openclaw 7.x request-scoped);
323
361
  // 读取失败不能拖垮整个 tick,这里吞掉并继续,消息下次再拉。
324
- console.warn(`[subagent-monitor] getSessionMessages failed childSessionKey=${record.childSessionKey} runId=${record.runId}: ${String(err)}`);
362
+ console.warn(`[subagent-monitor] getSessionMessages failed childSessionKey=${target.childSessionKey} runId=${target.runId}: ${String(err)}`);
325
363
  }
326
- for (const msg of messages.slice(record.lastMsgIndex))
327
- record.messages.push(cleanMsg(msg));
328
- record.lastMsgIndex = messages.length;
364
+ for (const msg of messages.slice(target.lastMsgIndex))
365
+ target.messages.push(cleanMsg(msg));
366
+ target.lastMsgIndex = messages.length;
329
367
  }
330
368
  finally {
331
- record.reading = false;
369
+ target.reading = false;
332
370
  }
333
371
  }
334
372
  /**
335
373
  * 用 openclaw 权威 run 状态对账:run 已结束(ok/error)返回 true,仍在运行(timeout)返回 false。
336
374
  * 探测失败(如 runId 已不存在、状态码异常)保守按「仍在运行」处理,交给事件路径兜底。
337
375
  */
338
- async function probeRunStatus(record) {
376
+ async function probeRunStatus(target) {
339
377
  if (!m.api)
340
378
  return false;
341
379
  try {
342
380
  const result = await m.api.runtime.subagent.waitForRun({
343
- runId: record.runId,
381
+ runId: target.runId,
344
382
  timeoutMs: RUN_STATUS_PROBE_TIMEOUT_MS,
345
383
  });
346
384
  return result.status !== "timeout";
347
385
  }
348
386
  catch (err) {
349
- console.warn(`[subagent-monitor] waitForRun probe failed runId=${record.runId}: ${String(err)}`);
387
+ console.warn(`[subagent-monitor] waitForRun probe failed runId=${target.runId}: ${String(err)}`);
350
388
  return false;
351
389
  }
352
390
  }
353
- function ensureTickerRunning() {
354
- if (m.ticker)
355
- return;
356
- void tick();
357
- m.ticker = setInterval(() => void tick(), POLL_INTERVAL_MS);
358
- m.ticker.unref?.();
359
- }
360
391
  async function tick() {
361
- if (roundMonitors.size === 0) {
392
+ prune();
393
+ if (targets.size === 0) {
362
394
  stopTickerIfIdle();
363
395
  return;
364
396
  }
365
397
  // 确保使用可用的 runtime:探测并必要时切回候选中的可用 api,避免坏 runtime 继续抛 RequestScoped。
366
398
  await resolveUsablePluginApi();
367
- for (const monitor of Array.from(roundMonitors.values())) {
399
+ const running = Array.from(targets.values()).filter((target) => !target.completed);
400
+ // pending 含:仍在跑的目标,以及已完成但终态快照尚未成功推送的目标(需重试补发)。
401
+ const pending = Array.from(targets.values()).filter((target) => !target.completed || !target.completedStatSent);
402
+ try {
403
+ await Promise.all(running.map((target) => readSubagentMessages(target)));
404
+ // 并行对账:已结束的 run 在 gateway 侧有 terminal snapshot 缓存,立即返回;仍在跑的会阻塞至探测超时。
405
+ await Promise.all(running.map(async (target) => {
406
+ if (target.completed)
407
+ return;
408
+ if (await probeRunStatus(target)) {
409
+ target.completed = true;
410
+ target.completedAt = Date.now();
411
+ }
412
+ }));
413
+ }
414
+ catch (err) {
415
+ console.error(`[subagent-monitor] poll failed: ${String(err)}`);
416
+ }
417
+ for (const target of pending) {
418
+ if (targets.get(target.childSessionKey) !== target)
419
+ continue;
368
420
  try {
369
- const runningRecords = Array.from(monitor.subagents.values()).filter((record) => !record.completed);
370
- await Promise.all(runningRecords.map((record) => readSubagentMessages(record)));
371
- if (roundMonitors.get(monitor.roundKey) !== monitor)
372
- continue;
373
- // 并行对账:已结束的 run 在 gateway 侧有 terminal snapshot 缓存,立即返回;仍在跑的会阻塞至探测超时。
374
- await Promise.all(runningRecords.map(async (record) => {
375
- if (record.completed)
376
- return;
377
- if (await probeRunStatus(record))
378
- record.completed = true;
379
- }));
380
- if (roundMonitors.get(monitor.roundKey) !== monitor)
381
- continue;
382
- sendWsStat(monitor, monitor.streamClient);
383
- await settleMonitorIfComplete(monitor);
421
+ if (target.completed) {
422
+ await pushCompleted(target);
423
+ maybeAllChildrenDone(target.roundKey, target.generation);
424
+ }
425
+ else {
426
+ await pushSubagentStat(target);
427
+ }
384
428
  }
385
429
  catch (err) {
386
- console.error(`[subagent-monitor] poll failed roundKey=${monitor.roundKey}: ${String(err)}`);
430
+ console.error(`[subagent-monitor] SUBAGENT_STAT failed childSessionKey=${target.childSessionKey}: ${String(err)}`);
387
431
  }
388
432
  }
389
433
  stopTickerIfIdle();
@@ -421,59 +465,62 @@ function cleanMsg(msg) {
421
465
  text = `${text.slice(0, MSG_TRUNCATE_LEN)}…`;
422
466
  return `[助手] ${text}${toolCount > 0 ? ` [调用${toolCount}个工具]` : ""}`;
423
467
  }
424
- function toSubagentList(monitor, incremental) {
425
- return Array.from(monitor.subagents.values()).map((record) => ({
426
- runId: record.runId,
427
- label: record.label ?? record.runId.slice(0, 8),
428
- status: record.completed ? "completed" : "running",
429
- messages: incremental
430
- ? record.messages.slice(record.sendFrom, record.sendFrom + MAX_INCR_MSGS)
431
- : record.messages,
432
- }));
433
- }
434
468
  /**
435
- * 客户端可见状态的稳定签名:新增子任务、状态翻转(running→completed)或消息数变化都会改变。
436
- * 仅凭它做「无变化不重复推送」的去重;同一轮多波次派发不会被锁死。
469
+ * 推送一条 SUBAGENT_STAT(单目标、增量消息;状态取目标当前 completed)。
470
+ * 发送失败只记录、不抛出:fire-and-forget 调用点(spawn/tick)不应产生 unhandledRejection。
471
+ * 返回是否发送成功(未推进 sendFrom 时下次可重试)。
437
472
  */
438
- function statSignature(monitor) {
439
- return Array.from(monitor.subagents.values())
440
- .map((r) => `${r.runId}:${r.completed ? "completed" : "running"}:${r.messages.length}`)
441
- .join("|");
442
- }
443
- function sendWsStat(monitor, client) {
444
- const signature = statSignature(monitor);
445
- // 有 running 子任务时保持逐 tick 推送(running/新增消息语义不变);
446
- // 全部已完成且状态无变化(等 turn-end 的空窗)则去重,避免 tick 刷同一份完成快照。
447
- const hasRunning = Array.from(monitor.subagents.values()).some((r) => !r.completed);
448
- if (!hasRunning && signature === monitor.lastPushedSignature)
449
- return;
450
- const subagentList = toSubagentList(monitor, true);
451
- client
452
- .sendEvent({
453
- groupId: monitor.groupId,
454
- replyToMsgId: monitor.replyToMsgId,
455
- event: "SUBAGENT_STAT",
456
- data: { subagentList },
457
- })
458
- .then(() => {
459
- subagentList.forEach((item) => {
460
- const record = monitor.subagents.get(item.runId);
461
- if (record)
462
- record.sendFrom += item.messages.length;
473
+ async function pushSubagentStat(target) {
474
+ if (!target.anchorMsgId) {
475
+ console.debug(`[subagent-monitor] SUBAGENT_STAT skipped (no anchor) childSessionKey=${target.childSessionKey} runId=${target.runId}`);
476
+ return false;
477
+ }
478
+ const messages = target.messages.slice(target.sendFrom, target.sendFrom + MAX_INCR_MSGS);
479
+ try {
480
+ await target.streamClient.sendEvent({
481
+ groupId: target.groupId,
482
+ replyToMsgId: target.anchorMsgId,
483
+ event: "SUBAGENT_STAT",
484
+ data: {
485
+ subagentList: [
486
+ {
487
+ runId: target.runId,
488
+ label: target.label,
489
+ status: target.completed ? "completed" : "running",
490
+ messages,
491
+ },
492
+ ],
493
+ },
463
494
  });
464
- monitor.lastPushedSignature = signature;
465
- })
466
- .catch((err) => {
467
- console.error(`[subagent-monitor] SUBAGENT_STAT failed: ${String(err)}`);
468
- });
495
+ }
496
+ catch (err) {
497
+ console.error(`[subagent-monitor] SUBAGENT_STAT failed childSessionKey=${target.childSessionKey} runId=${target.runId}: ${String(err)}`);
498
+ return false;
499
+ }
500
+ target.sendFrom += messages.length;
501
+ return true;
469
502
  }
470
- async function sendFinalStat(monitor, client) {
471
- await client.sendEvent({
472
- groupId: monitor.groupId,
473
- replyToMsgId: monitor.replyToMsgId,
474
- event: "SUBAGENT_STAT",
475
- data: { subagentList: toSubagentList(monitor, false) },
476
- });
503
+ /** 完成收尾:补发全部待推消息,并至少推送一次 completed 状态(幂等;发送失败下次重试)。 */
504
+ async function pushCompleted(target) {
505
+ if (target.completedStatSent || target.completedStatSending)
506
+ return;
507
+ if (!target.anchorMsgId) {
508
+ // 无锚点无法发送(buildSendEventPayload 要求非空);标记已处理,避免无意义重试与 ticker 空转。
509
+ target.completedStatSent = true;
510
+ return;
511
+ }
512
+ target.completedStatSending = true;
513
+ try {
514
+ while (target.sendFrom < target.messages.length) {
515
+ if (!(await pushSubagentStat(target)))
516
+ return;
517
+ }
518
+ if (await pushSubagentStat(target))
519
+ target.completedStatSent = true;
520
+ }
521
+ finally {
522
+ target.completedStatSending = false;
523
+ }
477
524
  }
478
525
  export function __tickForTests() {
479
526
  return tick();
@@ -489,9 +536,8 @@ export function disposeSubagentMonitor() {
489
536
  m.api = null;
490
537
  m.apiProbe = "unverified";
491
538
  m.apiCandidates.length = 0;
492
- childToRound.clear();
493
- roundMonitors.clear();
494
- deferredFinished.clear();
539
+ targets.clear();
540
+ groupAnchors.clear();
495
541
  roundAdmissionTombstones.clear();
496
542
  childTombstones.clear();
497
543
  }