@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 +26 -0
- package/dist/src/bot.js +35 -1
- package/dist/src/formatter.d.ts +5 -0
- package/dist/src/formatter.js +3 -3
- package/dist/src/outbound.d.ts +7 -0
- package/dist/src/outbound.js +93 -5
- package/dist/src/reply-dispatcher.js +38 -5
- package/dist/src/subagent-wait-state.d.ts +61 -0
- package/dist/src/subagent-wait-state.js +223 -0
- package/dist/src/task-manager.d.ts +9 -1
- package/dist/src/task-manager.js +49 -6
- package/package.json +1 -1
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);
|
package/dist/src/formatter.d.ts
CHANGED
|
@@ -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.
|
package/dist/src/formatter.js
CHANGED
|
@@ -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);
|
package/dist/src/outbound.d.ts
CHANGED
|
@@ -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.
|
package/dist/src/outbound.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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:
|
|
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。
|
package/dist/src/task-manager.js
CHANGED
|
@@ -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.
|
|
24
|
-
|
|
25
|
-
existing
|
|
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
|
-
|
|
44
|
-
|
|
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。
|