@ynhcj/xiaoyi-channel 0.0.187-next → 0.0.188-next

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/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { getAllPushIds } from "./src/utils/pushid-manager.js";
10
10
  import { registerSelfEvolutionToolResultNudge } from "./src/self-evolution-tool-result-nudge.js";
11
11
  import { createBeforePromptBuildHandler } from "./src/skill-retriever/hooks.js";
12
12
  import { normalizeToolRetrieverConfig } from "./src/skill-retriever/config.js";
13
+ import { markSubagentSpawned, markSubagentEnded, } from "./src/subagent-wait-state.js";
13
14
  /**
14
15
  * Register the cron detection hook.
15
16
  *
@@ -167,7 +168,32 @@ function readJobIdFromResult(result) {
167
168
  }
168
169
  return undefined;
169
170
  }
171
+ function registerSubagentHooks(api) {
172
+ // subagent_spawned: fires after a subagent run is successfully registered.
173
+ // We increment the expected completion count so that onIdle knows to wait.
174
+ api.on("subagent_spawned", async (_event, ctx) => {
175
+ const requesterSessionKey = ctx?.requesterSessionKey;
176
+ if (!requesterSessionKey)
177
+ return;
178
+ const count = markSubagentSpawned(requesterSessionKey);
179
+ if (count > 0) {
180
+ console.log(`[XY-SUBAGENT] spawned, requesterSessionKey=${requesterSessionKey.slice(0, 30)}, expected=${count}`);
181
+ }
182
+ });
183
+ // subagent_ended: fires when a subagent run terminates (complete/error/killed).
184
+ // We log the event for tracking; the actual completion count is decremented
185
+ // when the subagent result is delivered via xyOutbound.sendText.
186
+ api.on("subagent_ended", async (event, ctx) => {
187
+ const requesterSessionKey = ctx?.requesterSessionKey;
188
+ if (!requesterSessionKey)
189
+ return;
190
+ markSubagentEnded(requesterSessionKey);
191
+ console.log(`[XY-SUBAGENT] ended, sessionKey=${event?.targetSessionKey?.slice(0, 30)}, outcome=${event?.outcome}, requester=${requesterSessionKey.slice(0, 30)}`);
192
+ });
193
+ }
170
194
  function registerFullHooks(api) {
195
+ // SUBAGENT HOOKS: track subagent spawn/end lifecycle for session keep-alive
196
+ registerSubagentHooks(api);
171
197
  // SKILL RETRIEVER HOOK: before_prompt_build hook
172
198
  const pluginConfig = api.pluginConfig || {};
173
199
  const skillRetrieverConfig = normalizeToolRetrieverConfig({
package/dist/src/bot.js CHANGED
@@ -16,6 +16,7 @@ import { saveRuntimeInfo } from "./utils/runtime-manager.js";
16
16
  import { toolCallNudgeManager } from "./utils/tool-call-nudge-manager.js";
17
17
  import { setCsplSteerContext } from "./cspl/steer-context.js";
18
18
  import { registerTaskId, decrementTaskIdRef, hasActiveTask, } from "./task-manager.js";
19
+ import { registerSessionKeyMapping, getWaitState, markParentSettled, } from "./subagent-wait-state.js";
19
20
  import { logger } from "./utils/logger.js";
20
21
  /**
21
22
  * Handle an incoming A2A message.
@@ -177,6 +178,11 @@ export async function handleXYMessage(params) {
177
178
  },
178
179
  });
179
180
  log.log(`[BOT] Resolved route, sessionKey=${route.sessionKey}`);
181
+ // Register sessionKey→A2A sessionId mapping for subagent hook translation.
182
+ // Hooks receive openclaw sessionKey (e.g. "agent:main:direct:xxx") but
183
+ // xy_channel operates on A2A sessionId. This bridge lets subagent_spawned/
184
+ // subagent_ended hooks find the correct wait state.
185
+ registerSessionKeyMapping(route.sessionKey, parsed.sessionId, parsed.taskId, parsed.messageId);
180
186
  // Check for ACP runtime binding on this A2A conversation
181
187
  const runtimeRoute = resolveRuntimeConversationBindingRoute({
182
188
  route,
@@ -369,6 +375,12 @@ export async function handleXYMessage(params) {
369
375
  const cleanup = () => {
370
376
  if (cleaned)
371
377
  return;
378
+ const pendingWait = getWaitState(parsed.sessionId, parsed.taskId);
379
+ if (pendingWait && pendingWait.deliveredCompletions < pendingWait.expectedCompletions) {
380
+ // Subagent wait active — skip cleanup, session stays alive
381
+ log.log(`[BOT] Cleanup suppressed — subagent wait active ${pendingWait.deliveredCompletions}/${pendingWait.expectedCompletions}`);
382
+ return;
383
+ }
372
384
  cleaned = true;
373
385
  log.log(`[BOT] Cleanup started`);
374
386
  streamingSignals.delete(parsed.sessionId);
@@ -408,13 +420,35 @@ export async function handleXYMessage(params) {
408
420
  log.log(`[BOT-DISPATCH] withReplyDispatcher starting, sessionKey=${route.sessionKey}`);
409
421
  await core.channel.reply.withReplyDispatcher({
410
422
  dispatcher,
411
- onSettled: () => {
423
+ onSettled: async () => {
412
424
  log.log(`[BOT] onSettled, steered=${steerState.steered}`);
413
425
  // 🔑 When steered, skip cleanup — the first message's dispatcher is still running
414
426
  if (steerState.steered) {
415
427
  log.log(`[BOT] Steered dispatch settled, skipping cleanup`);
416
428
  return;
417
429
  }
430
+ // Subagent wait state: if parent has pending subagents, mark settled
431
+ // and defer finalization. Cleanup is suppressed so the session stays alive.
432
+ const pendingWait = getWaitState(parsed.sessionId, parsed.taskId);
433
+ if (pendingWait && !pendingWait.parentSettled) {
434
+ const transition = markParentSettled(parsed.sessionId, parsed.taskId);
435
+ if (transition?.shouldFinalize) {
436
+ // All completions arrived before parent settled → finalize now
437
+ const { deliverSubagentFinalResult } = await import("./outbound.js");
438
+ await deliverSubagentFinalResult({
439
+ config,
440
+ state: transition.state,
441
+ reason: "all-subagent-results-delivered-before-parent-settled",
442
+ });
443
+ streamingSignals.delete(parsed.sessionId);
444
+ log.log(`[BOT] Subagent wait complete on parent settled; final response delivered`);
445
+ }
446
+ else {
447
+ log.log(`[BOT] Subagent wait active, preserving session context for completion`);
448
+ }
449
+ dispatcherUpdaters.delete(parsed.sessionId);
450
+ return;
451
+ }
418
452
  // cleanup 已由 onIdleComplete 在 onIdle 的 finally 中执行。
419
453
  // onSettled 不做任何清理(直接在这里清理会发生 race condition)。
420
454
  dispatcherUpdaters.delete(parsed.sessionId);
@@ -10,6 +10,9 @@ export interface SendA2AResponseParams {
10
10
  text?: string;
11
11
  append: boolean;
12
12
  final: boolean;
13
+ /** Optional: reuse an existing artifactId instead of generating a new one.
14
+ * Used by subagent completion to link results to the original artifact. */
15
+ artifactId?: string;
13
16
  files?: Array<{
14
17
  fileName: string;
15
18
  fileType: string;
@@ -50,6 +53,8 @@ export interface SendStatusUpdateParams {
50
53
  messageId: string;
51
54
  text: string;
52
55
  state: "submitted" | "working" | "input-required" | "completed" | "canceled" | "failed" | "unknown";
56
+ /** When false, use the exact taskId/messageId passed in. Default true (dynamic lookup). */
57
+ useLatestTask?: boolean;
53
58
  }
54
59
  /**
55
60
  * Send an A2A task status update.
@@ -45,7 +45,7 @@ function buildTextPreview(text) {
45
45
  * Send an A2A artifact update response.
46
46
  */
47
47
  export async function sendA2AResponse(params) {
48
- const { config, sessionId, taskId, messageId, text, append, final, files, errorCode, errorMessage, log: shouldLog = true } = params;
48
+ const { config, sessionId, taskId, messageId, text, append, final, artifactId, files, errorCode, errorMessage, log: shouldLog = true } = params;
49
49
  const log = logger.withContext(sessionId, taskId);
50
50
  // 审批桥接:将 OpenClaw 的审批提示翻译成用户友好的确认文案
51
51
  const bridgedText = text === undefined ? text : rewriteOutboundApprovalText(sessionId, text);
@@ -57,7 +57,7 @@ export async function sendA2AResponse(params) {
57
57
  lastChunk: true,
58
58
  final,
59
59
  artifact: {
60
- artifactId: uuidv4(),
60
+ artifactId: artifactId ?? uuidv4(),
61
61
  parts: [],
62
62
  },
63
63
  };
@@ -157,7 +157,7 @@ export async function sendReasoningTextUpdate(params) {
157
157
  * Follows A2A protocol standard format with nested status object.
158
158
  */
159
159
  export async function sendStatusUpdate(params) {
160
- const { config, sessionId, taskId, messageId, text, state } = params;
160
+ const { config, sessionId, taskId, messageId, text, state, useLatestTask = true } = params;
161
161
  const log = logger.withContext(sessionId, taskId);
162
162
  // 审批桥接和脱敏
163
163
  const bridgedText = rewriteOutboundApprovalText(sessionId, text);
@@ -1,4 +1,11 @@
1
1
  type ChannelOutboundAdapter = any;
2
+ import { resolveXYConfig } from "./config.js";
3
+ export declare function deliverSubagentFinalResult(params: {
4
+ config: ReturnType<typeof resolveXYConfig>;
5
+ state: import("./subagent-wait-state.js").SubagentWaitState;
6
+ reason?: string;
7
+ text?: string;
8
+ }): Promise<void>;
2
9
  /**
3
10
  * Outbound adapter for sending messages from OpenClaw to XY.
4
11
  * Uses Push service for direct message delivery.
@@ -6,11 +6,45 @@ import { resolveXYConfig } from "./config.js";
6
6
  import { XYFileUploadService } from "./file-upload.js";
7
7
  import { XYPushService } from "./push.js";
8
8
  import { getCurrentSessionContext } from "./tools/session-manager.js";
9
+ import { getWaitState, markCompletionDelivered, clearWaitState, } from "./subagent-wait-state.js";
10
+ import { decrementTaskIdRef } from "./task-manager.js";
11
+ import { sendA2AResponse, sendStatusUpdate } from "./formatter.js";
9
12
  import { savePushData } from "./utils/pushdata-manager.js";
10
13
  import { getAllPushIds } from "./utils/pushid-manager.js";
11
14
  import { logger } from "./utils/logger.js";
12
15
  // Special marker for default push delivery when no target is specified
13
16
  const DEFAULT_PUSH_MARKER = "default";
17
+ // ─── Subagent completion delivery ──────────────────────────────
18
+ export async function deliverSubagentFinalResult(params) {
19
+ const { config, state, reason, text } = params;
20
+ const log = logger.withContext(state.sessionId, state.taskId);
21
+ const finalText = state.completionTexts.length > 0
22
+ ? state.completionTexts.join("\n\n")
23
+ : (text ?? "");
24
+ await sendStatusUpdate({
25
+ config,
26
+ sessionId: state.sessionId,
27
+ taskId: state.taskId,
28
+ messageId: state.messageId,
29
+ text: "任务处理已完成~",
30
+ state: "completed",
31
+ useLatestTask: false,
32
+ });
33
+ await sendA2AResponse({
34
+ config,
35
+ sessionId: state.sessionId,
36
+ taskId: state.taskId,
37
+ messageId: state.messageId,
38
+ text: finalText,
39
+ append: false,
40
+ final: true,
41
+ artifactId: state.artifactId,
42
+ });
43
+ clearWaitState(state.sessionId, reason ?? "all-subagent-results-delivered", state.taskId);
44
+ decrementTaskIdRef(state.sessionId, state.taskId);
45
+ log.log(`[xyOutbound] Subagent final delivered to original A2A task, reason=${reason}`);
46
+ }
47
+ // ─── MIME types ─────────────────────────────────────────────────
14
48
  // File extension to MIME type mapping
15
49
  const FILE_TYPE_TO_MIME_TYPE = {
16
50
  txt: "text/plain",
@@ -72,20 +106,28 @@ export const xyOutbound = {
72
106
  // If the target doesn't contain "::", try to enhance it with taskId from session context
73
107
  if (!trimmedTo.includes("::")) {
74
108
  logger.log(`[xyOutbound.resolveTarget] Target "${trimmedTo}" missing taskId, looking up session context`);
75
- // Try to get the current session context
109
+ // Try ALS context first (normal agent turn)
76
110
  const sessionContext = getCurrentSessionContext();
77
111
  if (sessionContext && sessionContext.sessionId === trimmedTo) {
78
112
  const enhancedTarget = `${trimmedTo}::${sessionContext.taskId}`;
79
- logger.log(`[xyOutbound.resolveTarget] Enhanced target: ${enhancedTarget}`);
113
+ logger.log(`[xyOutbound.resolveTarget] Enhanced target from ALS: ${enhancedTarget}`);
80
114
  return {
81
115
  ok: true,
82
116
  to: enhancedTarget,
83
117
  };
84
118
  }
85
- else {
86
- logger.log(`[xyOutbound.resolveTarget] Could not find matching session context for "${trimmedTo}"`);
87
- // Still return the original target, but it may fail in sendMedia
119
+ // Fallback: subagent wait state (ALS is null for subagent completion deliveries)
120
+ const waitState = getWaitState(trimmedTo);
121
+ if (waitState) {
122
+ const enhancedTarget = `${waitState.sessionId}::${waitState.taskId}`;
123
+ logger.withContext(waitState.sessionId, waitState.taskId).log(`[xyOutbound.resolveTarget] Enhanced target from subagent wait state: ${enhancedTarget}`);
124
+ return {
125
+ ok: true,
126
+ to: enhancedTarget,
127
+ };
88
128
  }
129
+ logger.log(`[xyOutbound.resolveTarget] Could not find matching session context or wait state for "${trimmedTo}"`);
130
+ // Still return the original target, but it may fail in sendMedia
89
131
  }
90
132
  // Otherwise, use the provided target (either already in correct format or for sendText)
91
133
  logger.log(`[xyOutbound.resolveTarget] Using provided target:`, trimmedTo);
@@ -97,6 +139,52 @@ export const xyOutbound = {
97
139
  sendText: async ({ cfg, to, text, accountId }) => {
98
140
  // Resolve configuration
99
141
  const config = resolveXYConfig(cfg);
142
+ // ── Subagent completion interception ─────────────────────────
143
+ // Isolation guards:
144
+ // 1. ALS context is null (subagent completions arrive outside any agent turn)
145
+ // 2. A wait state exists for the target sessionId
146
+ // 3. The taskId matches (enhanced by resolveTarget from wait state)
147
+ const alsCtx = getCurrentSessionContext();
148
+ if (!alsCtx && to && to !== DEFAULT_PUSH_MARKER) {
149
+ const [targetSessionId, targetTaskId] = String(to).split("::");
150
+ const waitState = getWaitState(targetSessionId, targetTaskId);
151
+ if (waitState && !waitState.finalizationClaimed) {
152
+ const delivered = markCompletionDelivered(targetSessionId, targetTaskId, text);
153
+ if (delivered) {
154
+ const { state, isComplete, shouldFinalize } = delivered;
155
+ const log = logger.withContext(state.sessionId, state.taskId);
156
+ if (shouldFinalize) {
157
+ // All completions arrived and parent already settled → send final
158
+ log.log(`[xyOutbound.sendText] All subagent results arrived after parent settled, finalizing`);
159
+ await deliverSubagentFinalResult({ config, state, reason: "all-subagent-results-delivered-after-parent-settled", text: text });
160
+ }
161
+ else if (isComplete) {
162
+ // All completions arrived but parent hasn't settled yet → defer
163
+ log.log(`[xyOutbound.sendText] All subagent results arrived before parent settled, deferring finalization`);
164
+ }
165
+ else {
166
+ // Still waiting for more completions → send progress update
167
+ log.log(`[xyOutbound.sendText] Subagent completion ${state.deliveredCompletions}/${state.expectedCompletions}`);
168
+ try {
169
+ await sendStatusUpdate({
170
+ config,
171
+ sessionId: state.sessionId,
172
+ taskId: state.taskId,
173
+ messageId: state.messageId,
174
+ text: `已收到子任务结果 ${state.deliveredCompletions}/${state.expectedCompletions},继续等待其他子任务~`,
175
+ state: "working",
176
+ useLatestTask: false,
177
+ });
178
+ }
179
+ catch (err) {
180
+ log.error(`[xyOutbound.sendText] Failed to send subagent progress status:`, err);
181
+ }
182
+ }
183
+ }
184
+ // Continue with push delivery (subagent result also delivered as push)
185
+ }
186
+ }
187
+ // ── End subagent interception ───────────────────────────────
100
188
  // Handle default push marker (for cron jobs without explicit target)
101
189
  let actualTo = to;
102
190
  if (to === DEFAULT_PUSH_MARKER) {
@@ -2,6 +2,7 @@ import { getXYRuntime } from "./runtime.js";
2
2
  import { sendA2AResponse, sendStatusUpdate, sendReasoningTextUpdate, sendCommand } from "./formatter.js";
3
3
  import { resolveXYConfig } from "./config.js";
4
4
  import { clearRunCrossTaskSentFiles, getCurrentSessionContext } from "./tools/session-manager.js";
5
+ import { getWaitState, clearWaitState, attachHeartbeat, } from "./subagent-wait-state.js";
5
6
  import fs from "fs/promises";
6
7
  import path from "path";
7
8
  import { logger } from "./utils/logger.js";
@@ -187,8 +188,6 @@ export function createXYReplyDispatcher(params) {
187
188
  scopedLog().log(`[DELIVER SKIP] Already sent via onPartialReply`);
188
189
  return;
189
190
  }
190
- // DEBUG: 记录 deliver 发送内容
191
- scopedLog().log(`[DEBUG-DEDUP] deliver: text_len=${text.length}, accumulated_len=${accumulatedText.length}, text_preview="${text.slice(0, 100)}", accumulated_preview="${accumulatedText.slice(0, 100)}"`);
192
191
  accumulatedText += text;
193
192
  hasSentResponse = true;
194
193
  // 🔑 使用动态taskId发送A2A响应(流式append)
@@ -234,6 +233,34 @@ export function createXYReplyDispatcher(params) {
234
233
  stopStatusInterval();
235
234
  return;
236
235
  }
236
+ // ── Subagent wait state check ─────────────────────────────
237
+ // If this session has pending subagent completions, suppress final:true
238
+ // and keep the A2A session alive. The final response will be sent when
239
+ // all completions arrive via xyOutbound.sendText interception.
240
+ const waitState = getWaitState(sessionId, taskId);
241
+ if (waitState &&
242
+ waitState.deliveredCompletions < waitState.expectedCompletions &&
243
+ !finalSent) {
244
+ scopedLog().log(`[ON-IDLE] Waiting for subagent completions ${waitState.deliveredCompletions}/${waitState.expectedCompletions}, suppressing final:true`);
245
+ try {
246
+ await sendStatusUpdate({
247
+ config,
248
+ sessionId,
249
+ taskId,
250
+ messageId,
251
+ text: "子任务正在处理中,请稍候~",
252
+ state: "working",
253
+ useLatestTask: false,
254
+ });
255
+ }
256
+ catch (err) {
257
+ scopedLog().error(`[ON-IDLE] Failed to send subagent waiting status:`, err);
258
+ }
259
+ // Attach heartbeat so the status interval stays alive during wait
260
+ attachHeartbeat(sessionId, taskId, stopStatusInterval);
261
+ return;
262
+ }
263
+ // ── End subagent wait state check ─────────────────────────
237
264
  // 🔑 用 try/finally 确保 cleanup 在 onIdle 的 async 工作全部完成后才执行。
238
265
  // openclaw 的 waitForIdle() 以 void options.onIdle?.() 调用 onIdle,
239
266
  // 不会 await 返回的 Promise,因此 onSettled 可能在 onIdle 中途触发。
@@ -280,6 +307,9 @@ export function createXYReplyDispatcher(params) {
280
307
  final: true,
281
308
  });
282
309
  finalSent = true;
310
+ if (waitState) {
311
+ clearWaitState(sessionId, "main-final-delivered", taskId);
312
+ }
283
313
  scopedLog().log(`[ON-IDLE] Sent final response (empty, stream end)`);
284
314
  }
285
315
  catch (err) {
@@ -430,8 +460,11 @@ export function createXYReplyDispatcher(params) {
430
460
  if (dedupApplied) {
431
461
  sendText = text.slice(accumulatedText.length);
432
462
  }
433
- // DEBUG: 记录 onPartialReply 去重详情
434
- scopedLog().log(`[DEBUG-DEDUP] onPartialReply: text_len=${text.length}, accumulated_len=${accumulatedText.length}, sendText_len=${sendText.length}, dedup=${dedupApplied}, text_preview="${text.slice(0, 100)}", accumulated_preview="${accumulatedText.slice(0, 100)}", sendText_preview="${sendText.slice(0, 100)}"`);
463
+ else {
464
+ // 新文本不以已累积内容为前缀(如工具调用后模型重新开始生成),
465
+ // 更新 accumulatedText 为当前文本,后续基于此新前缀做去重
466
+ accumulatedText = "";
467
+ }
435
468
  accumulatedText += sendText;
436
469
  hasSentResponse = true;
437
470
  if (sendText.length > 0) {
@@ -443,7 +476,7 @@ export function createXYReplyDispatcher(params) {
443
476
  text: sendText,
444
477
  append: true,
445
478
  final: false,
446
- log: true,
479
+ log: false,
447
480
  });
448
481
  }
449
482
  }
@@ -0,0 +1,61 @@
1
+ export interface SubagentWaitState {
2
+ sessionId: string;
3
+ sessionKey: string;
4
+ taskId: string;
5
+ messageId: string;
6
+ artifactId: string;
7
+ startedAt: number;
8
+ expectedCompletions: number;
9
+ deliveredCompletions: number;
10
+ completionTexts: string[];
11
+ parentSettled: boolean;
12
+ finalizationClaimed: boolean;
13
+ stopHeartbeat?: () => void;
14
+ }
15
+ export interface SubagentWaitTransition {
16
+ state: SubagentWaitState;
17
+ isComplete: boolean;
18
+ shouldFinalize: boolean;
19
+ }
20
+ /**
21
+ * Register a mapping from openclaw sessionKey to A2A sessionId/taskId.
22
+ * Called in bot.ts when a new message dispatch begins.
23
+ */
24
+ export declare function registerSessionKeyMapping(sessionKey: string, sessionId: string, taskId: string, messageId: string): void;
25
+ /**
26
+ * Look up the A2A sessionId for a given openclaw sessionKey.
27
+ * Used in hooks (index.ts) to translate requesterSessionKey to A2A sessionId.
28
+ */
29
+ export declare function resolveSessionIdFromSessionKey(sessionKey: string): {
30
+ sessionId: string;
31
+ taskId: string;
32
+ messageId: string;
33
+ } | null;
34
+ /**
35
+ * Remove a sessionKey→sessionId mapping (cleanup when wait state resolved).
36
+ */
37
+ export declare function unregisterSessionKeyMapping(sessionKey: string): void;
38
+ /**
39
+ * Called from subagent_spawned hook. Increments expected completion count.
40
+ * If no wait state exists yet, creates one (sessions_spawn may happen before sessions_yield).
41
+ */
42
+ export declare function markSubagentSpawned(sessionKey: string): number;
43
+ /**
44
+ * Called from subagent_ended hook. Marks a subagent run as ended.
45
+ * Does NOT decrement expected count — that's done when completion is delivered via outbound.
46
+ */
47
+ export declare function markSubagentEnded(sessionKey: string): void;
48
+ /**
49
+ * Called from xyOutbound.sendText when a subagent completion is delivered.
50
+ * Increments delivered count and returns transition state.
51
+ */
52
+ export declare function markCompletionDelivered(sessionId: string, taskId: string, text?: string): SubagentWaitTransition | null;
53
+ /**
54
+ * Called from bot.ts onSettled. Marks parent dispatcher as settled.
55
+ * Returns transition with shouldFinalize if all completions already arrived.
56
+ */
57
+ export declare function markParentSettled(sessionId: string, taskId: string): SubagentWaitTransition | null;
58
+ export declare function attachHeartbeat(sessionId: string, taskId: string, stopHeartbeat: () => void): void;
59
+ export declare function getWaitState(sessionId: string, taskId?: string): SubagentWaitState | null;
60
+ export declare function hasWaitState(sessionId: string, taskId?: string): boolean;
61
+ export declare function clearWaitState(sessionId: string, reason: string, taskId?: string): SubagentWaitState | null;
@@ -0,0 +1,223 @@
1
+ // Subagent wait state manager.
2
+ //
3
+ // Tracks sessions that are waiting for subagent completions after sessions_yield.
4
+ // Subagent spawn/end is detected via subagent_spawned / subagent_ended hooks (index.ts).
5
+ // Completion delivery is intercepted in xyOutbound.sendText (outbound.ts).
6
+ //
7
+ // Isolation: xyOutbound.sendText also serves cron pushes and normal message-tool calls.
8
+ // Guards ensure these paths never collide with subagent result interception:
9
+ // 1. ALS context guard — subagent completions arrive with ALS null
10
+ // 2. Wait state match — only sessions that have yielded have a wait state
11
+ // 3. TaskId match — subagent delivery's "to" is a bare sessionId, enhanced via wait state
12
+ //
13
+ // TTL: wait states expire after WAIT_TTL_MS (30 min) to avoid stale leaks.
14
+ // Only one finalization path wins via finalizationClaimed flag.
15
+ import { randomUUID } from "crypto";
16
+ import { logger } from "./utils/logger.js";
17
+ // ─── Global state ──────────────────────────────────────────────
18
+ const WAIT_TTL_MS = 30 * 60 * 1000; // 30 minutes
19
+ const _g = globalThis;
20
+ if (!_g.__xySubagentWaitStates) {
21
+ _g.__xySubagentWaitStates = new Map();
22
+ }
23
+ if (!_g.__xySessionKeyToSessionId) {
24
+ _g.__xySessionKeyToSessionId = new Map();
25
+ }
26
+ const waitStates = _g.__xySubagentWaitStates;
27
+ const sessionKeyMap = _g.__xySessionKeyToSessionId;
28
+ // ─── Helpers ───────────────────────────────────────────────────
29
+ function waitKey(sessionId, taskId) {
30
+ return `${sessionId}::${taskId}`;
31
+ }
32
+ function isExpired(state) {
33
+ return Date.now() - state.startedAt > WAIT_TTL_MS;
34
+ }
35
+ function isComplete(state) {
36
+ return state.deliveredCompletions >= Math.max(1, state.expectedCompletions);
37
+ }
38
+ function claimFinalizationIfReady(state) {
39
+ if (state.finalizationClaimed)
40
+ return false;
41
+ if (!state.parentSettled || !isComplete(state))
42
+ return false;
43
+ state.finalizationClaimed = true;
44
+ return true;
45
+ }
46
+ // ─── SessionKey ↔ SessionId mapping ────────────────────────────
47
+ /**
48
+ * Register a mapping from openclaw sessionKey to A2A sessionId/taskId.
49
+ * Called in bot.ts when a new message dispatch begins.
50
+ */
51
+ export function registerSessionKeyMapping(sessionKey, sessionId, taskId, messageId) {
52
+ if (!sessionKey || !sessionId)
53
+ return;
54
+ sessionKeyMap.set(sessionKey, { sessionId, taskId, messageId });
55
+ }
56
+ /**
57
+ * Look up the A2A sessionId for a given openclaw sessionKey.
58
+ * Used in hooks (index.ts) to translate requesterSessionKey to A2A sessionId.
59
+ */
60
+ export function resolveSessionIdFromSessionKey(sessionKey) {
61
+ return sessionKeyMap.get(sessionKey) ?? null;
62
+ }
63
+ /**
64
+ * Remove a sessionKey→sessionId mapping (cleanup when wait state resolved).
65
+ */
66
+ export function unregisterSessionKeyMapping(sessionKey) {
67
+ sessionKeyMap.delete(sessionKey);
68
+ }
69
+ // ─── Wait state management ─────────────────────────────────────
70
+ function getStatesArray(sessionId) {
71
+ const raw = waitStates.get(sessionId);
72
+ if (!raw)
73
+ return [];
74
+ const active = raw.filter((s) => {
75
+ if (!isExpired(s))
76
+ return true;
77
+ s.stopHeartbeat?.();
78
+ logger.withContext(sessionId, s.taskId).log(`[SUBAGENT-WAIT] Expired wait state cleared, expected=${s.expectedCompletions}, delivered=${s.deliveredCompletions}`);
79
+ return false;
80
+ });
81
+ if (active.length > 0) {
82
+ waitStates.set(sessionId, active);
83
+ }
84
+ else {
85
+ waitStates.delete(sessionId);
86
+ }
87
+ return active;
88
+ }
89
+ /**
90
+ * Called from subagent_spawned hook. Increments expected completion count.
91
+ * If no wait state exists yet, creates one (sessions_spawn may happen before sessions_yield).
92
+ */
93
+ export function markSubagentSpawned(sessionKey) {
94
+ const mapped = resolveSessionIdFromSessionKey(sessionKey);
95
+ if (!mapped) {
96
+ logger.log(`[SUBAGENT-WAIT] No session mapping for sessionKey=${sessionKey.slice(0, 30)}`);
97
+ return 0;
98
+ }
99
+ const { sessionId, taskId, messageId } = mapped;
100
+ const states = getStatesArray(sessionId);
101
+ let state = states.find((s) => s.taskId === taskId);
102
+ if (!state) {
103
+ state = {
104
+ sessionId,
105
+ sessionKey,
106
+ taskId,
107
+ messageId,
108
+ artifactId: randomUUID(),
109
+ startedAt: Date.now(),
110
+ expectedCompletions: 0,
111
+ deliveredCompletions: 0,
112
+ completionTexts: [],
113
+ parentSettled: false,
114
+ finalizationClaimed: false,
115
+ };
116
+ states.push(state);
117
+ }
118
+ state.expectedCompletions = Math.max(1, state.expectedCompletions + 1);
119
+ waitStates.set(sessionId, states);
120
+ logger.withContext(sessionId, taskId).log(`[SUBAGENT-WAIT] Subagent spawned, expected=${state.expectedCompletions}`);
121
+ return state.expectedCompletions;
122
+ }
123
+ /**
124
+ * Called from subagent_ended hook. Marks a subagent run as ended.
125
+ * Does NOT decrement expected count — that's done when completion is delivered via outbound.
126
+ */
127
+ export function markSubagentEnded(sessionKey) {
128
+ const mapped = resolveSessionIdFromSessionKey(sessionKey);
129
+ if (!mapped)
130
+ return;
131
+ const { sessionId, taskId } = mapped;
132
+ const states = getStatesArray(sessionId);
133
+ const state = states.find((s) => s.taskId === taskId);
134
+ if (!state)
135
+ return;
136
+ logger.withContext(sessionId, taskId).log(`[SUBAGENT-WAIT] Subagent ended, delivered=${state.deliveredCompletions}/${state.expectedCompletions}`);
137
+ }
138
+ /**
139
+ * Called from xyOutbound.sendText when a subagent completion is delivered.
140
+ * Increments delivered count and returns transition state.
141
+ */
142
+ export function markCompletionDelivered(sessionId, taskId, text) {
143
+ const states = getStatesArray(sessionId);
144
+ const state = states.find((s) => s.taskId === taskId);
145
+ if (!state) {
146
+ logger.withContext(sessionId, taskId).log(`[SUBAGENT-WAIT] No wait state matched for completion delivery`);
147
+ return null;
148
+ }
149
+ state.deliveredCompletions += 1;
150
+ if (text?.trim()) {
151
+ state.completionTexts.push(text.trim());
152
+ }
153
+ const complete = isComplete(state);
154
+ const shouldFinalize = claimFinalizationIfReady(state);
155
+ waitStates.set(sessionId, states);
156
+ logger.withContext(sessionId, taskId).log(`[SUBAGENT-WAIT] Completion delivered=${state.deliveredCompletions}/${state.expectedCompletions}, complete=${complete}, shouldFinalize=${shouldFinalize}`);
157
+ return { state, isComplete: complete, shouldFinalize };
158
+ }
159
+ /**
160
+ * Called from bot.ts onSettled. Marks parent dispatcher as settled.
161
+ * Returns transition with shouldFinalize if all completions already arrived.
162
+ */
163
+ export function markParentSettled(sessionId, taskId) {
164
+ const states = getStatesArray(sessionId);
165
+ const state = states.find((s) => s.taskId === taskId);
166
+ if (!state) {
167
+ logger.withContext(sessionId, taskId).log(`[SUBAGENT-WAIT] No wait state matched for parent settled`);
168
+ return null;
169
+ }
170
+ state.parentSettled = true;
171
+ const complete = isComplete(state);
172
+ const shouldFinalize = claimFinalizationIfReady(state);
173
+ waitStates.set(sessionId, states);
174
+ logger.withContext(sessionId, taskId).log(`[SUBAGENT-WAIT] Parent settled, completions=${state.deliveredCompletions}/${state.expectedCompletions}, shouldFinalize=${shouldFinalize}`);
175
+ return { state, isComplete: complete, shouldFinalize };
176
+ }
177
+ export function attachHeartbeat(sessionId, taskId, stopHeartbeat) {
178
+ const states = getStatesArray(sessionId);
179
+ const state = states.find((s) => s.taskId === taskId);
180
+ if (!state) {
181
+ logger.withContext(sessionId, taskId).log(`[SUBAGENT-WAIT] No wait state matched for heartbeat attachment`);
182
+ return;
183
+ }
184
+ state.stopHeartbeat = stopHeartbeat;
185
+ waitStates.set(sessionId, states);
186
+ logger.withContext(sessionId, taskId).log(`[SUBAGENT-WAIT] Heartbeat attached`);
187
+ }
188
+ // ─── Query functions ───────────────────────────────────────────
189
+ export function getWaitState(sessionId, taskId) {
190
+ const states = getStatesArray(sessionId);
191
+ if (states.length === 0)
192
+ return null;
193
+ if (taskId) {
194
+ return states.find((s) => s.taskId === taskId) ?? null;
195
+ }
196
+ // FIFO: return oldest wait state when taskId not specified
197
+ return states[0] ?? null;
198
+ }
199
+ export function hasWaitState(sessionId, taskId) {
200
+ return getWaitState(sessionId, taskId) !== null;
201
+ }
202
+ export function clearWaitState(sessionId, reason, taskId) {
203
+ const states = getStatesArray(sessionId);
204
+ if (states.length === 0)
205
+ return null;
206
+ const index = taskId
207
+ ? states.findIndex((s) => s.taskId === taskId)
208
+ : 0;
209
+ if (index < 0) {
210
+ logger.withContext(sessionId, taskId ?? "").log(`[SUBAGENT-WAIT] No wait state matched for clear, reason=${reason}`);
211
+ return null;
212
+ }
213
+ const [state] = states.splice(index, 1);
214
+ state.stopHeartbeat?.();
215
+ if (states.length > 0) {
216
+ waitStates.set(sessionId, states);
217
+ }
218
+ else {
219
+ waitStates.delete(sessionId);
220
+ }
221
+ logger.withContext(sessionId, state.taskId).log(`[SUBAGENT-WAIT] Cleared wait state, reason=${reason}, remaining=${states.length}`);
222
+ return state;
223
+ }
@@ -1,8 +1,14 @@
1
+ interface TaskIdEntry {
2
+ taskId: string;
3
+ messageId: string;
4
+ updatedAt: number;
5
+ }
1
6
  interface TaskIdBinding {
2
7
  sessionId: string;
3
8
  currentTaskId: string;
4
9
  currentMessageId: string;
5
10
  updatedAt: number;
11
+ tasks?: TaskIdEntry[];
6
12
  }
7
13
  /**
8
14
  * 注册或更新session的活跃taskId。
@@ -11,8 +17,10 @@ interface TaskIdBinding {
11
17
  export declare function registerTaskId(sessionId: string, taskId: string, messageId: string): boolean;
12
18
  /**
13
19
  * 移除session的活跃taskId(消息处理完成时调用)。
20
+ * @param expectedTaskId 可选:精确移除指定的 taskId,而非整个 session 绑定。
21
+ * 用于 subagent completion 等需要精确清理特定 task 的场景。
14
22
  */
15
- export declare function decrementTaskIdRef(sessionId: string): void;
23
+ export declare function decrementTaskIdRef(sessionId: string, expectedTaskId?: string): void;
16
24
  /**
17
25
  * 获取session的当前活跃taskId。
18
26
  * 仅供 reply-dispatcher 跨链读取 steer 更新后的 taskId。
@@ -12,6 +12,27 @@ if (!_g.__xyActiveTaskIds) {
12
12
  _g.__xyActiveTaskIds = new Map();
13
13
  }
14
14
  const activeTaskIds = _g.__xyActiveTaskIds;
15
+ function normalizeTasks(binding) {
16
+ if (Array.isArray(binding.tasks) && binding.tasks.length > 0) {
17
+ return binding.tasks;
18
+ }
19
+ return [{
20
+ taskId: binding.currentTaskId,
21
+ messageId: binding.currentMessageId,
22
+ updatedAt: binding.updatedAt,
23
+ }];
24
+ }
25
+ function syncCurrent(binding, tasks) {
26
+ binding.tasks = tasks;
27
+ const current = tasks[tasks.length - 1];
28
+ if (!current) {
29
+ activeTaskIds.delete(binding.sessionId);
30
+ return;
31
+ }
32
+ binding.currentTaskId = current.taskId;
33
+ binding.currentMessageId = current.messageId;
34
+ binding.updatedAt = current.updatedAt;
35
+ }
15
36
  /**
16
37
  * 注册或更新session的活跃taskId。
17
38
  * Returns true if this was an update (session already had an active task).
@@ -20,9 +41,9 @@ export function registerTaskId(sessionId, taskId, messageId) {
20
41
  const existing = activeTaskIds.get(sessionId);
21
42
  if (existing) {
22
43
  logger.log(`[TASK_MANAGER] Updating taskId: ${existing.currentTaskId} → ${taskId}`);
23
- existing.currentTaskId = taskId;
24
- existing.currentMessageId = messageId;
25
- existing.updatedAt = Date.now();
44
+ const tasks = normalizeTasks(existing).filter((entry) => entry.taskId !== taskId);
45
+ tasks.push({ taskId, messageId, updatedAt: Date.now() });
46
+ syncCurrent(existing, tasks);
26
47
  return true; // isUpdate
27
48
  }
28
49
  else {
@@ -31,6 +52,7 @@ export function registerTaskId(sessionId, taskId, messageId) {
31
52
  currentTaskId: taskId,
32
53
  currentMessageId: messageId,
33
54
  updatedAt: Date.now(),
55
+ tasks: [{ taskId, messageId, updatedAt: Date.now() }],
34
56
  });
35
57
  logger.log(`[TASK_MANAGER] Registered new taskId: ${taskId}`);
36
58
  return false;
@@ -38,10 +60,31 @@ export function registerTaskId(sessionId, taskId, messageId) {
38
60
  }
39
61
  /**
40
62
  * 移除session的活跃taskId(消息处理完成时调用)。
63
+ * @param expectedTaskId 可选:精确移除指定的 taskId,而非整个 session 绑定。
64
+ * 用于 subagent completion 等需要精确清理特定 task 的场景。
41
65
  */
42
- export function decrementTaskIdRef(sessionId) {
43
- logger.log(`[TASK_MANAGER] Removing taskId`);
44
- activeTaskIds.delete(sessionId);
66
+ export function decrementTaskIdRef(sessionId, expectedTaskId) {
67
+ if (!expectedTaskId) {
68
+ logger.log(`[TASK_MANAGER] Removing taskId`);
69
+ activeTaskIds.delete(sessionId);
70
+ return;
71
+ }
72
+ const existing = activeTaskIds.get(sessionId);
73
+ if (!existing)
74
+ return;
75
+ const tasks = normalizeTasks(existing);
76
+ const nextTasks = tasks.filter((entry) => entry.taskId !== expectedTaskId);
77
+ if (nextTasks.length === tasks.length) {
78
+ logger.log(`[TASK_MANAGER] Preserving taskId ${existing.currentTaskId}; completed task ${expectedTaskId} is not active`);
79
+ return;
80
+ }
81
+ syncCurrent(existing, nextTasks);
82
+ if (nextTasks.length > 0) {
83
+ logger.log(`[TASK_MANAGER] Removed taskId ${expectedTaskId}, restored current taskId ${existing.currentTaskId}`);
84
+ }
85
+ else {
86
+ logger.log(`[TASK_MANAGER] Removing taskId (last task ${expectedTaskId})`);
87
+ }
45
88
  }
46
89
  /**
47
90
  * 获取session的当前活跃taskId。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynhcj/xiaoyi-channel",
3
- "version": "0.0.187-next",
3
+ "version": "0.0.188-next",
4
4
  "description": "OpenClaw Xiaoyi Channel plugin - Xiaoyi A2A protocol integration",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",