@ynhcj/xiaoyi-channel 0.0.231-next → 0.0.232-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 +171 -6
- package/dist/src/channel.js +5 -2
- package/dist/src/conversation/conversation-manager.d.ts +12 -3
- package/dist/src/conversation/conversation-manager.js +89 -8
- package/dist/src/conversation/conversation-session.d.ts +16 -0
- package/dist/src/conversation/conversation-session.js +5 -0
- package/dist/src/conversation/cron-buffer.d.ts +36 -11
- package/dist/src/conversation/cron-buffer.js +157 -56
- package/dist/src/conversation/outbound-gateway.d.ts +4 -0
- package/dist/src/conversation/outbound-gateway.js +2 -2
- package/dist/src/conversation/outbound-queue.d.ts +35 -0
- package/dist/src/conversation/outbound-queue.js +96 -0
- package/dist/src/conversation/stream-assembler.d.ts +52 -0
- package/dist/src/conversation/stream-assembler.js +122 -0
- package/dist/src/cron-command.js +2 -24
- package/dist/src/cspl/call_api.js +8 -2
- package/dist/src/cspl/constants.d.ts +1 -0
- package/dist/src/cspl/constants.js +2 -0
- package/dist/src/cspl/sentinel_hook.js +6 -9
- package/dist/src/cspl/utils.d.ts +2 -2
- package/dist/src/cspl/utils.js +8 -8
- package/dist/src/dispatch/bot.js +43 -3
- package/dist/src/dispatch/outbound.js +14 -5
- package/dist/src/dispatch/reply-dispatcher.js +283 -155
- package/dist/src/dynamic-skill-trigger.d.ts +8 -0
- package/dist/src/dynamic-skill-trigger.js +62 -0
- package/dist/src/formatter.d.ts +7 -0
- package/dist/src/formatter.js +71 -94
- package/dist/src/provider.js +87 -4
- package/dist/src/reply-dispatcher.js +16 -12
- package/dist/src/self-evolution-handler.d.ts +1 -1
- package/dist/src/skill-retriever/config.js +2 -2
- package/dist/src/skill-retriever/hooks.js +34 -13
- package/dist/src/skill-retriever/skill-status.d.ts +22 -0
- package/dist/src/skill-retriever/skill-status.js +125 -0
- package/dist/src/skill-retriever/tool-search.d.ts +4 -2
- package/dist/src/skill-retriever/tool-search.js +75 -15
- package/dist/src/skill-retriever/types.d.ts +1 -0
- package/dist/src/tools/agent-as-skill-tool.js +1 -1
- package/dist/src/tools/calendar-tool.js +1 -1
- package/dist/src/tools/call-device-tool.js +24 -13
- package/dist/src/tools/call-phone-tool.js +4 -2
- package/dist/src/tools/check-plugin-privilege-tool.js +1 -1
- package/dist/src/tools/create-alarm-tool.js +1 -1
- package/dist/src/tools/delete-alarm-tool.js +1 -1
- package/dist/src/tools/discover-cross-devices-tool.js +1 -1
- package/dist/src/tools/display-a2ui-card-bypath-tool.js +43 -10
- package/dist/src/tools/hmos-cli.js +2 -2
- package/dist/src/tools/invoke.js +4 -4
- package/dist/src/tools/location-tool.js +1 -1
- package/dist/src/tools/login-token-tool.js +1 -1
- package/dist/src/tools/modify-alarm-tool.js +1 -1
- package/dist/src/tools/modify-note-tool.js +1 -1
- package/dist/src/tools/note-tool.js +1 -1
- package/dist/src/tools/query-app-message-tool.js +1 -1
- package/dist/src/tools/query-memory-data-tool.js +1 -1
- package/dist/src/tools/query-todo-task-tool.js +1 -1
- package/dist/src/tools/save-file-to-phone-tool.js +1 -1
- package/dist/src/tools/save-media-to-gallery-tool.js +1 -1
- package/dist/src/tools/search-alarm-tool.js +1 -1
- package/dist/src/tools/search-calendar-tool.js +1 -1
- package/dist/src/tools/search-contact-tool.js +1 -1
- package/dist/src/tools/search-email-tool.js +1 -1
- package/dist/src/tools/search-file-tool.js +1 -1
- package/dist/src/tools/search-message-tool.js +1 -1
- package/dist/src/tools/search-note-tool.js +1 -1
- package/dist/src/tools/search-photo-gallery-tool.js +1 -1
- package/dist/src/tools/send-cross-device-task-tool.js +1 -1
- package/dist/src/tools/send-email-tool.js +1 -2
- package/dist/src/tools/send-file-to-user-tool.js +1 -1
- package/dist/src/tools/send-message-tool.js +4 -2
- package/dist/src/tools/session-manager.d.ts +4 -4
- package/dist/src/tools/session-manager.js +23 -12
- package/dist/src/tools/upload-file-tool.js +1 -1
- package/dist/src/tools/upload-photo-tool.js +1 -1
- package/dist/src/tools/xiaoyi-add-collection-tool.js +1 -1
- package/dist/src/tools/xiaoyi-collection-tool.js +1 -1
- package/dist/src/tools/xiaoyi-delete-collection-tool.js +1 -1
- package/dist/src/tools/xiaoyi-gui-tool.js +3 -3
- package/dist/src/transport/push.d.ts +3 -1
- package/dist/src/transport/push.js +9 -1
- package/dist/src/trigger-handler.js +1 -1
- package/dist/src/utils/async-mutex.d.ts +5 -0
- package/dist/src/utils/async-mutex.js +14 -0
- package/dist/src/utils/cron-push-map.js +29 -21
- package/dist/src/utils/pushdata-manager.d.ts +9 -1
- package/dist/src/utils/pushdata-manager.js +28 -19
- package/package.json +1 -1
|
@@ -21,37 +21,55 @@
|
|
|
21
21
|
// · 宽限期结束无 announce(路径 B)→ 缓冲文本合并为一条 push 发出。
|
|
22
22
|
// - 120s 安全超时兜底:强制 flush 缓冲并关闭,防泄漏到下一个 run。
|
|
23
23
|
//
|
|
24
|
-
//
|
|
24
|
+
// 并发设计:按 jobId 隔离(Map<jobId, CronTurnState>)。openclaw 保证同一
|
|
25
|
+
// job 的不同 run 不重叠(runningAtMs 拦截),jobId 是安全键;jobId 缺失时
|
|
26
|
+
// 归入 `__unknown__` 桶。sendText 层无法直接拿到 jobId/runId(openclaw 不
|
|
27
|
+
// 把 session 上下文传给 channel adapter),路由规则见 gateCronSendText:
|
|
28
|
+
// - announce 类(无 target,to === DEFAULT_PUSH_MARKER)→ 路由到处于
|
|
29
|
+
// agentEnded 宽限的 turn(多个时按 endedAt FIFO,匹配 openclaw 按 run
|
|
30
|
+
// 完成顺序投递 announce);
|
|
31
|
+
// - 中间消息(有真实 target)→ 恰好 1 个活跃 turn 时吞入缓冲;多活跃
|
|
32
|
+
// turn(真并发、无法归属)安全回退:不聚合,按普通 push 原样发送。
|
|
25
33
|
import { logger } from "../utils/logger.js";
|
|
26
34
|
import { pushBroadcast } from "./outbound-gateway.js";
|
|
35
|
+
import { savePushData } from "../utils/pushdata-manager.js";
|
|
27
36
|
/** 与 isCronActive 的 120s TTL 对齐:超时强制关闭,防崩溃泄漏到下一个 run。 */
|
|
28
37
|
const CRON_SAFETY_TIMEOUT_MS = 120_000;
|
|
29
38
|
/** run 结束后等待 announce 的宽限时间。 */
|
|
30
39
|
const ANNOUNCE_GRACE_MS = 10_000;
|
|
31
40
|
const MAX_BUFFERED_KEPT = 20;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
/** jobId 缺失(无法归属)时的兜底桶。 */
|
|
42
|
+
const UNKNOWN_JOB_KEY = "__unknown__";
|
|
43
|
+
function normalizeKey(jobId) {
|
|
44
|
+
return jobId ?? UNKNOWN_JOB_KEY;
|
|
35
45
|
}
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
const _g = globalThis;
|
|
47
|
+
if (!(_g.__xyCronTurns instanceof Map)) {
|
|
48
|
+
_g.__xyCronTurns = new Map();
|
|
38
49
|
}
|
|
39
|
-
function
|
|
40
|
-
_g.
|
|
50
|
+
function getTurnRegistry() {
|
|
51
|
+
return _g.__xyCronTurns;
|
|
41
52
|
}
|
|
42
|
-
/**
|
|
43
|
-
export function closeCronTurn(reason) {
|
|
44
|
-
const
|
|
53
|
+
/** 关闭指定 job 的 cron turn(清理全部定时器)。 */
|
|
54
|
+
export function closeCronTurn(jobId, reason) {
|
|
55
|
+
const key = normalizeKey(jobId);
|
|
56
|
+
const turn = getTurnRegistry().get(key);
|
|
45
57
|
if (!turn)
|
|
46
58
|
return;
|
|
47
59
|
clearTimeout(turn.safetyTimer);
|
|
48
60
|
if (turn.graceTimer)
|
|
49
61
|
clearTimeout(turn.graceTimer);
|
|
50
|
-
|
|
51
|
-
logger.log(`[CRON-BUFFER] Cron turn closed, reason=${reason}`);
|
|
62
|
+
getTurnRegistry().delete(key);
|
|
63
|
+
logger.log(`[CRON-BUFFER] Cron turn closed, jobId=${jobId ?? "-"}, reason=${reason}`);
|
|
52
64
|
}
|
|
53
|
-
/**
|
|
54
|
-
|
|
65
|
+
/**
|
|
66
|
+
* 将缓冲文本合并为一条 push 发出(截断沿用 1000 字限制)。
|
|
67
|
+
* meta 必须由调用方在 closeCronTurn 之前从 turn 捕获传入 —— close 后
|
|
68
|
+
* turn 已从 registry 移除,flush 内部无法再读取 cron 元数据。
|
|
69
|
+
* 持久化用全文 merged(客户端 Trigger 回查看全文,不受截断影响);
|
|
70
|
+
* savePushData 失败时 pushDataId="" 回落 kind:"text" 内联,行为同现状。
|
|
71
|
+
*/
|
|
72
|
+
async function flushBufferedTexts(texts, config, reason, meta) {
|
|
55
73
|
const merged = texts.join("\n\n").trim();
|
|
56
74
|
if (!merged) {
|
|
57
75
|
logger.log(`[CRON-BUFFER] Nothing to flush (${reason})`);
|
|
@@ -62,14 +80,23 @@ async function flushBufferedTexts(texts, config, reason) {
|
|
|
62
80
|
return;
|
|
63
81
|
}
|
|
64
82
|
const pushText = merged.length > 1000 ? merged.slice(0, 1000) : merged;
|
|
65
|
-
|
|
83
|
+
let pushDataId = "";
|
|
84
|
+
try {
|
|
85
|
+
pushDataId = await savePushData(merged, { cronJobId: meta?.jobId, cronTitle: meta?.jobTitle });
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
logger.error(`[CRON-BUFFER] Failed to save pushData for flush (${reason}):`, err);
|
|
89
|
+
}
|
|
90
|
+
logger.log(`[CRON-BUFFER] Flushing merged text (${reason}), len=${pushText.length}, pushDataId=${pushDataId || "-"}`);
|
|
66
91
|
try {
|
|
67
92
|
await pushBroadcast({
|
|
68
93
|
config,
|
|
69
94
|
text: pushText,
|
|
70
95
|
title: pushText.split("\n")[0].slice(0, 57),
|
|
71
96
|
to: config.defaultSessionId || "",
|
|
72
|
-
pushDataId
|
|
97
|
+
pushDataId,
|
|
98
|
+
cronJobId: meta?.jobId,
|
|
99
|
+
cronTitle: meta?.jobTitle,
|
|
73
100
|
});
|
|
74
101
|
}
|
|
75
102
|
catch (err) {
|
|
@@ -80,36 +107,46 @@ async function flushBufferedTexts(texts, config, reason) {
|
|
|
80
107
|
* cron turn 开始检测。由 provider.ts 在识别到 [cron:...] 首条消息时调用。
|
|
81
108
|
* 同一 cron run 的每次模型调用都会重复触发检测 —— 此时仅刷新安全定时器,
|
|
82
109
|
* 不重开 turn(避免 churn,也避免重置 agentEnded/缓冲状态)。
|
|
110
|
+
* 按 jobId 隔离:不同 job 并发触发时各自独立建 turn,后到 job 的 jobId
|
|
111
|
+
* 不再被覆盖丢弃。
|
|
83
112
|
*/
|
|
84
|
-
export function notifyCronDetected(jobId) {
|
|
85
|
-
const
|
|
113
|
+
export function notifyCronDetected(jobId, jobTitle) {
|
|
114
|
+
const key = normalizeKey(jobId);
|
|
115
|
+
const registry = getTurnRegistry();
|
|
116
|
+
const existing = registry.get(key);
|
|
86
117
|
if (existing) {
|
|
87
|
-
// 同一
|
|
118
|
+
// 同一 job 的重复检测:刷新安全定时器,回填缺失的 title
|
|
88
119
|
clearTimeout(existing.safetyTimer);
|
|
89
|
-
existing.safetyTimer = armSafetyTimer();
|
|
120
|
+
existing.safetyTimer = armSafetyTimer(key);
|
|
90
121
|
existing.startedAt = Date.now();
|
|
122
|
+
if (!existing.jobTitle && jobTitle)
|
|
123
|
+
existing.jobTitle = jobTitle;
|
|
91
124
|
return;
|
|
92
125
|
}
|
|
93
126
|
const turn = {
|
|
94
127
|
jobId,
|
|
128
|
+
jobTitle,
|
|
95
129
|
startedAt: Date.now(),
|
|
130
|
+
endedAt: 0,
|
|
96
131
|
agentEnded: false,
|
|
97
132
|
bufferedTexts: [],
|
|
98
|
-
safetyTimer: armSafetyTimer(),
|
|
133
|
+
safetyTimer: armSafetyTimer(key),
|
|
99
134
|
};
|
|
100
|
-
|
|
101
|
-
logger.log(`[CRON-BUFFER] Cron turn opened, jobId=${jobId ?? "unknown"}`);
|
|
135
|
+
registry.set(key, turn);
|
|
136
|
+
logger.log(`[CRON-BUFFER] Cron turn opened, jobId=${jobId ?? "unknown"}, title=${jobTitle ?? "-"}`);
|
|
102
137
|
}
|
|
103
|
-
function armSafetyTimer() {
|
|
138
|
+
function armSafetyTimer(key) {
|
|
104
139
|
const timer = setTimeout(() => {
|
|
105
|
-
const turn =
|
|
140
|
+
const turn = getTurnRegistry().get(key);
|
|
106
141
|
if (!turn)
|
|
107
142
|
return;
|
|
108
|
-
logger.warn(`[CRON-BUFFER] Safety timeout (${CRON_SAFETY_TIMEOUT_MS}ms) reached, agentEnded=${turn.agentEnded}, buffered=${turn.bufferedTexts.length}`);
|
|
143
|
+
logger.warn(`[CRON-BUFFER] Safety timeout (${CRON_SAFETY_TIMEOUT_MS}ms) reached, jobId=${turn.jobId ?? "-"}, agentEnded=${turn.agentEnded}, buffered=${turn.bufferedTexts.length}`);
|
|
109
144
|
const flushed = turn.bufferedTexts.slice();
|
|
110
|
-
|
|
145
|
+
const meta = { jobId: turn.jobId, jobTitle: turn.jobTitle };
|
|
146
|
+
const config = turn.config;
|
|
147
|
+
closeCronTurn(turn.jobId, "safety-timeout");
|
|
111
148
|
if (flushed.length > 0) {
|
|
112
|
-
void flushBufferedTexts(flushed,
|
|
149
|
+
void flushBufferedTexts(flushed, config, "safety-timeout", meta);
|
|
113
150
|
}
|
|
114
151
|
}, CRON_SAFETY_TIMEOUT_MS);
|
|
115
152
|
// 安全定时器不应阻止进程退出
|
|
@@ -118,51 +155,115 @@ function armSafetyTimer() {
|
|
|
118
155
|
}
|
|
119
156
|
/**
|
|
120
157
|
* cron run 结束信号(lifecycle phase=end,由 index.ts 的 agent event
|
|
121
|
-
* subscription
|
|
158
|
+
* subscription 调用,jobId 从 event.sessionKey 解析)。只结束该 job 的
|
|
159
|
+
* turn,进入 announce 宽限期:
|
|
122
160
|
* - 宽限期内 announce 到达 → 放行(见 gateCronSendText);
|
|
123
161
|
* - 宽限期结束无 announce → 合并缓冲文本发一条(路径 B)。
|
|
162
|
+
* jobId 缺失(sessionKey 解析失败)时:若恰有 1 个活跃 turn,兼容旧单槽
|
|
163
|
+
* 行为结束之;多个活跃 turn 时无法归属,跳过并告警。
|
|
124
164
|
*/
|
|
125
|
-
export function notifyCronAgentEnd() {
|
|
126
|
-
const
|
|
127
|
-
|
|
165
|
+
export function notifyCronAgentEnd(jobId) {
|
|
166
|
+
const key = normalizeKey(jobId);
|
|
167
|
+
const registry = getTurnRegistry();
|
|
168
|
+
const turn = registry.get(key);
|
|
169
|
+
if (!turn) {
|
|
170
|
+
if (!jobId) {
|
|
171
|
+
const actives = Array.from(registry.values()).filter((t) => !t.agentEnded);
|
|
172
|
+
if (actives.length === 1) {
|
|
173
|
+
startGrace(actives[0], key);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (actives.length > 1) {
|
|
177
|
+
logger.warn(`[CRON-BUFFER] notifyCronAgentEnd without jobId but ${actives.length} active turns — skipping (cannot attribute)`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
128
180
|
return;
|
|
181
|
+
}
|
|
182
|
+
startGrace(turn, key);
|
|
183
|
+
}
|
|
184
|
+
function startGrace(turn, key) {
|
|
129
185
|
turn.agentEnded = true;
|
|
186
|
+
turn.endedAt = Date.now();
|
|
130
187
|
if (turn.graceTimer)
|
|
131
188
|
clearTimeout(turn.graceTimer);
|
|
132
189
|
turn.graceTimer = setTimeout(() => {
|
|
133
|
-
const t =
|
|
190
|
+
const t = getTurnRegistry().get(key);
|
|
134
191
|
if (!t)
|
|
135
192
|
return;
|
|
136
|
-
logger.log(`[CRON-BUFFER] Announce grace expired, buffered=${t.bufferedTexts.length}`);
|
|
193
|
+
logger.log(`[CRON-BUFFER] Announce grace expired, jobId=${t.jobId ?? "-"}, buffered=${t.bufferedTexts.length}`);
|
|
137
194
|
const flushed = t.bufferedTexts.slice();
|
|
138
|
-
|
|
195
|
+
const meta = { jobId: t.jobId, jobTitle: t.jobTitle };
|
|
196
|
+
const config = t.config;
|
|
197
|
+
closeCronTurn(t.jobId, "announce-grace-expired");
|
|
139
198
|
if (flushed.length > 0) {
|
|
140
|
-
void flushBufferedTexts(flushed,
|
|
199
|
+
void flushBufferedTexts(flushed, config, "announce-grace-expired", meta);
|
|
141
200
|
}
|
|
142
201
|
}, ANNOUNCE_GRACE_MS);
|
|
143
202
|
turn.graceTimer.unref?.();
|
|
144
|
-
logger.log(`[CRON-BUFFER] Cron agent ended, announce grace period (${ANNOUNCE_GRACE_MS}ms) started`);
|
|
203
|
+
logger.log(`[CRON-BUFFER] Cron agent ended, jobId=${turn.jobId ?? "-"}, announce grace period (${ANNOUNCE_GRACE_MS}ms) started`);
|
|
145
204
|
}
|
|
146
205
|
/**
|
|
147
|
-
* sendText
|
|
148
|
-
*
|
|
149
|
-
* -
|
|
150
|
-
*
|
|
151
|
-
*
|
|
206
|
+
* sendText 闸门。sendText 层拿不到 jobId/runId,只能凭 announceLike
|
|
207
|
+
* (to 是否为 DEFAULT_PUSH_MARKER,即是否有显式 target)做粗路由:
|
|
208
|
+
* - announceLike(无 target):run 结束后的 announce 最终结果,路由到处于
|
|
209
|
+
* agentEnded 宽限的 turn。多个 ended turn(两端几乎同时结束的窄竞态)按
|
|
210
|
+
* endedAt FIFO,与 openclaw 按 run 完成顺序投递 announce 一致。
|
|
211
|
+
* - 非 announceLike(有 target):run 期间的中间 sendText,吞入活跃 turn
|
|
212
|
+
* 缓冲。恰 1 个活跃 turn 正常吞;多活跃 turn(真并发、无法归属)安全回退
|
|
213
|
+
* 返回 "none"(不聚合、原样发送,宁可多推也不让 pushText/cronId 串台)。
|
|
214
|
+
* - "none":无 cron turn 或无法归属,按普通 push 发送。
|
|
152
215
|
*/
|
|
153
|
-
export function gateCronSendText(swallowedText, config) {
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
216
|
+
export function gateCronSendText(swallowedText, announceLike, config) {
|
|
217
|
+
const registry = getTurnRegistry();
|
|
218
|
+
const turns = Array.from(registry.values());
|
|
219
|
+
if (turns.length === 0)
|
|
220
|
+
return { gate: "none", meta: null };
|
|
221
|
+
if (announceLike) {
|
|
222
|
+
const ended = turns.filter((t) => t.agentEnded).sort((a, b) => a.endedAt - b.endedAt);
|
|
223
|
+
if (ended.length > 0) {
|
|
224
|
+
const t = ended[0];
|
|
225
|
+
if (config)
|
|
226
|
+
t.config = config;
|
|
227
|
+
return {
|
|
228
|
+
gate: "announce",
|
|
229
|
+
meta: { jobId: t.jobId, jobTitle: t.jobTitle },
|
|
230
|
+
jobId: t.jobId,
|
|
231
|
+
};
|
|
165
232
|
}
|
|
233
|
+
// run 未结束但发了一条无 target 的中间消息(罕见):恰 1 活跃 turn 时吞入。
|
|
234
|
+
const active = turns.filter((t) => !t.agentEnded);
|
|
235
|
+
if (active.length === 1) {
|
|
236
|
+
const t = active[0];
|
|
237
|
+
if (config)
|
|
238
|
+
t.config = config;
|
|
239
|
+
if (swallowedText && swallowedText.trim()) {
|
|
240
|
+
t.bufferedTexts.push(swallowedText.trim());
|
|
241
|
+
if (t.bufferedTexts.length > MAX_BUFFERED_KEPT)
|
|
242
|
+
t.bufferedTexts.shift();
|
|
243
|
+
}
|
|
244
|
+
return { gate: "swallow", meta: null, jobId: t.jobId };
|
|
245
|
+
}
|
|
246
|
+
// 多活跃 turn:无法归属,安全回退。
|
|
247
|
+
return { gate: "none", meta: null };
|
|
248
|
+
}
|
|
249
|
+
// 中间消息(有真实 target)
|
|
250
|
+
const active = turns.filter((t) => !t.agentEnded);
|
|
251
|
+
if (active.length === 1) {
|
|
252
|
+
const t = active[0];
|
|
253
|
+
if (config)
|
|
254
|
+
t.config = config;
|
|
255
|
+
if (swallowedText && swallowedText.trim()) {
|
|
256
|
+
t.bufferedTexts.push(swallowedText.trim());
|
|
257
|
+
if (t.bufferedTexts.length > MAX_BUFFERED_KEPT)
|
|
258
|
+
t.bufferedTexts.shift();
|
|
259
|
+
}
|
|
260
|
+
return { gate: "swallow", meta: null, jobId: t.jobId };
|
|
261
|
+
}
|
|
262
|
+
if (active.length === 0) {
|
|
263
|
+
// 所有 turn 均已结束:迟到/兜底的 sendText(如 message 工具结果晚于
|
|
264
|
+
// announce),按普通 push 发送,不再聚合。
|
|
265
|
+
return { gate: "none", meta: null };
|
|
166
266
|
}
|
|
167
|
-
|
|
267
|
+
// 多活跃 turn(真并发重叠):无法归属,安全回退。
|
|
268
|
+
return { gate: "none", meta: null };
|
|
168
269
|
}
|
|
@@ -18,6 +18,10 @@ export interface PushBroadcastParams {
|
|
|
18
18
|
/** 目标会话标识(push 服务侧使用,可为空字符串)。 */
|
|
19
19
|
to: string;
|
|
20
20
|
pushDataId: string;
|
|
21
|
+
/** cron 推送时携带:任务 jobId(随 kind="data" 下发给客户端) */
|
|
22
|
+
cronJobId?: string;
|
|
23
|
+
/** cron 推送时携带:任务标题 */
|
|
24
|
+
cronTitle?: string;
|
|
21
25
|
}
|
|
22
26
|
export interface PushBroadcastResult {
|
|
23
27
|
successCount: number;
|
|
@@ -29,7 +29,7 @@ export async function sendWsFrame(params) {
|
|
|
29
29
|
* 向所有已注册 pushId 广播推送通知(单 pushId 失败不影响其他)。
|
|
30
30
|
*/
|
|
31
31
|
export async function pushBroadcast(params) {
|
|
32
|
-
const { config, text, title, to, pushDataId } = params;
|
|
32
|
+
const { config, text, title, to, pushDataId, cronJobId, cronTitle } = params;
|
|
33
33
|
let pushIdList = [];
|
|
34
34
|
try {
|
|
35
35
|
pushIdList = await getAllPushIds();
|
|
@@ -45,7 +45,7 @@ export async function pushBroadcast(params) {
|
|
|
45
45
|
let failureCount = 0;
|
|
46
46
|
for (const pushId of pushIdList) {
|
|
47
47
|
try {
|
|
48
|
-
await pushService.sendPush(text, title, undefined, to, pushDataId, pushId);
|
|
48
|
+
await pushService.sendPush(text, title, undefined, to, pushDataId, pushId, cronJobId, cronTitle);
|
|
49
49
|
successCount++;
|
|
50
50
|
logger.log(`[outbound-gateway] Push sent to pushId: ${pushId.substring(0, 20)}...`);
|
|
51
51
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface OutboundFrame {
|
|
2
|
+
/** 帧归属的 taskId(purge 的粒度)。 */
|
|
3
|
+
taskId: string;
|
|
4
|
+
/** 日志标签(帧用途描述)。 */
|
|
5
|
+
label: string;
|
|
6
|
+
/**
|
|
7
|
+
* 合并键:drain 取帧时,若队列后方存在同 key 帧,则跳过本帧。
|
|
8
|
+
* 仅 append:false 全量文本帧应设置(如 `partial:${taskId}`)。
|
|
9
|
+
*/
|
|
10
|
+
coalesceKey?: string;
|
|
11
|
+
/** 发送前延迟(drain 内 sleep,阻塞后续帧)。 */
|
|
12
|
+
delayMs?: number;
|
|
13
|
+
/** 实际发送闭包(帧应在入队前完成组帧与上下文捕获)。 */
|
|
14
|
+
send: () => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export declare class OutboundQueue {
|
|
17
|
+
private readonly sessionId;
|
|
18
|
+
private items;
|
|
19
|
+
private draining;
|
|
20
|
+
/** 当前 drain 循环的 Promise;空闲时为已 resolve 的 Promise。 */
|
|
21
|
+
private drainPromise;
|
|
22
|
+
private destroyed;
|
|
23
|
+
constructor(sessionId: string);
|
|
24
|
+
enqueue(frame: OutboundFrame): void;
|
|
25
|
+
/** 丢弃指定 taskId 的全部待发帧(steer/cancel 场景;在途帧无法撤回)。 */
|
|
26
|
+
purge(taskId: string): void;
|
|
27
|
+
/** 会话销毁:丢弃全部待发帧,后续入队直接丢弃。 */
|
|
28
|
+
destroy(): void;
|
|
29
|
+
/**
|
|
30
|
+
* 等待队列排空。等待期间新入队的帧也会被等到(循环检查)。
|
|
31
|
+
* drain 不 reject(帧级异常已内部捕获),此 Promise 不会失败。
|
|
32
|
+
*/
|
|
33
|
+
whenIdle(): Promise<void>;
|
|
34
|
+
private drain;
|
|
35
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Outbound queue — 对话管理层的每会话出站队列。
|
|
2
|
+
//
|
|
3
|
+
// 收拢原散落在 reply-dispatcher processingLock / 各处直发中的时序职责:
|
|
4
|
+
// 所有发往 xy server 的帧(partial/status/final/命令/工具注入)都经此队列
|
|
5
|
+
// 串行发出,形成会话级唯一时序收口点。
|
|
6
|
+
//
|
|
7
|
+
// 设计要点:
|
|
8
|
+
// - drain 单飞串行:取帧 → coalescing 检查 → delayMs sleep → await send()。
|
|
9
|
+
// 单帧异常只记日志,不阻塞后续帧。
|
|
10
|
+
// - coalescing 仅作用于 append:false 全量文本帧(自覆盖语义:同 key 只发
|
|
11
|
+
// 最新一帧是安全的);status/final/命令帧不传 coalesceKey,永不参与。
|
|
12
|
+
// - delayMs 在 drain 内 sleep,阻塞后续帧 —— 正是终态帧延迟想要的语义:
|
|
13
|
+
// 让已发出的长正文先穿过下游服务端慢速管道(openclaw6.6 3e7b1aa/cdc4cdc)。
|
|
14
|
+
// - whenIdle() 等待队列排空,含等待期间新入队的帧。
|
|
15
|
+
// - 本模块只做时序,不组帧不感知 transport;send 闭包由调用方构造。
|
|
16
|
+
import { logger } from "../utils/logger.js";
|
|
17
|
+
function sleep(ms) {
|
|
18
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
}
|
|
20
|
+
export class OutboundQueue {
|
|
21
|
+
sessionId;
|
|
22
|
+
items = [];
|
|
23
|
+
draining = false;
|
|
24
|
+
/** 当前 drain 循环的 Promise;空闲时为已 resolve 的 Promise。 */
|
|
25
|
+
drainPromise = Promise.resolve();
|
|
26
|
+
destroyed = false;
|
|
27
|
+
constructor(sessionId) {
|
|
28
|
+
this.sessionId = sessionId;
|
|
29
|
+
}
|
|
30
|
+
enqueue(frame) {
|
|
31
|
+
if (this.destroyed) {
|
|
32
|
+
logger.withContext(this.sessionId, frame.taskId).log(`[OUTBOUND-QUEUE] Queue destroyed, dropping frame: ${frame.label}`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
this.items.push(frame);
|
|
36
|
+
if (!this.draining) {
|
|
37
|
+
this.draining = true;
|
|
38
|
+
this.drainPromise = this.drain();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** 丢弃指定 taskId 的全部待发帧(steer/cancel 场景;在途帧无法撤回)。 */
|
|
42
|
+
purge(taskId) {
|
|
43
|
+
const before = this.items.length;
|
|
44
|
+
this.items = this.items.filter((f) => f.taskId !== taskId);
|
|
45
|
+
const dropped = before - this.items.length;
|
|
46
|
+
if (dropped > 0) {
|
|
47
|
+
logger.withContext(this.sessionId, taskId).log(`[OUTBOUND-QUEUE] Purged ${dropped} pending frame(s) for task`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** 会话销毁:丢弃全部待发帧,后续入队直接丢弃。 */
|
|
51
|
+
destroy() {
|
|
52
|
+
this.destroyed = true;
|
|
53
|
+
this.items = [];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 等待队列排空。等待期间新入队的帧也会被等到(循环检查)。
|
|
57
|
+
* drain 不 reject(帧级异常已内部捕获),此 Promise 不会失败。
|
|
58
|
+
*/
|
|
59
|
+
async whenIdle() {
|
|
60
|
+
while (this.draining || this.items.length > 0) {
|
|
61
|
+
await this.drainPromise;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async drain() {
|
|
65
|
+
const log = logger.withContext(this.sessionId, "");
|
|
66
|
+
try {
|
|
67
|
+
while (this.items.length > 0) {
|
|
68
|
+
const frame = this.items.shift();
|
|
69
|
+
// coalescing:后方有同 key 帧时本帧可丢(全量帧自覆盖语义)。
|
|
70
|
+
if (frame.coalesceKey &&
|
|
71
|
+
this.items.some((f) => f.coalesceKey === frame.coalesceKey)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (frame.delayMs && frame.delayMs > 0) {
|
|
75
|
+
log.log(`[OUTBOUND-QUEUE] Delaying frame by ${frame.delayMs}ms: ${frame.label}`);
|
|
76
|
+
await sleep(frame.delayMs);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
await frame.send();
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
logger.withContext(this.sessionId, frame.taskId).error(`[OUTBOUND-QUEUE] Frame send failed: ${frame.label}`, err);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
this.draining = false;
|
|
88
|
+
// finally 期间可能有新帧入队(draining 已置 false 时 enqueue 会自启
|
|
89
|
+
// 新 drain;此处兜底:若 enqueue 发生在 draining=false 判定之前则补一轮)。
|
|
90
|
+
if (this.items.length > 0 && !this.draining) {
|
|
91
|
+
this.draining = true;
|
|
92
|
+
this.drainPromise = this.drain();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface AssemblerSegment {
|
|
2
|
+
kind: "model" | "injected";
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
export interface FinalizeResult {
|
|
6
|
+
/** 终帧应携带的全文本(可能为空字符串 —— 异常路径,调用方回退空帧语义)。 */
|
|
7
|
+
fullText: string;
|
|
8
|
+
/** 最后一轮模型调用的解析文本(canonical 优先),供跨任务结果消息使用。 */
|
|
9
|
+
resolvedLastText: string;
|
|
10
|
+
/** 权威文本与流式累计的对比诊断(无权威文本时为 "no-canonical")。 */
|
|
11
|
+
diagnostic: "match" | "patched-tail" | "canonical-shorter" | "diverged" | "no-canonical";
|
|
12
|
+
}
|
|
13
|
+
export declare class StreamAssembler {
|
|
14
|
+
private segments;
|
|
15
|
+
/** 当前模型调用的流式累计文本(同一调用内递增)。 */
|
|
16
|
+
private currentModelText;
|
|
17
|
+
/** deliver(kind=final) 捕获的权威最终文本(最后一次模型调用的 canonical 文本)。 */
|
|
18
|
+
private canonicalFinalText;
|
|
19
|
+
/**
|
|
20
|
+
* onPartialReply 喂入。text 是当前模型调用的累计清洗文本(非增量)。
|
|
21
|
+
* 同一调用内 text 递增;跨调用时 text 刷新(!startsWith),此时把上一个
|
|
22
|
+
* 调用的完整文本锁存为 model 段。
|
|
23
|
+
*/
|
|
24
|
+
onStreamText(text: string): {
|
|
25
|
+
fullText: string;
|
|
26
|
+
latched: boolean;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* 工具注入非模型文本(如卡片 DSL)。先锁存当前模型文本(保持相对位置),
|
|
30
|
+
* 再把注入内容作为独立段压入。注入后所有全量帧自然携带该段,
|
|
31
|
+
* 客户端 append:false 整体替换不会抹掉注入内容。
|
|
32
|
+
*/
|
|
33
|
+
injectArtifact(text: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* deliver(kind=final) 喂入权威文本。调用方必须已排除 compaction 通知和
|
|
36
|
+
* ⚙️ 系统通知(它们不是答案文本)。
|
|
37
|
+
*/
|
|
38
|
+
onFinalText(text: string): void;
|
|
39
|
+
/**
|
|
40
|
+
* onIdle 收口:计算终帧全文本。
|
|
41
|
+
* canonical 非空时替换 currentModelText;current 为空(已锁存/无流式)时
|
|
42
|
+
* 替换最后一个 model 段;完全没有 model 内容(ACP 纯 deliver 路径)时
|
|
43
|
+
* fullText 就是 canonical 本身。
|
|
44
|
+
*/
|
|
45
|
+
finalize(): FinalizeResult;
|
|
46
|
+
/** 全文本:已锁存段以 "\n" 连接,再接当前模型文本。 */
|
|
47
|
+
getFullText(): string;
|
|
48
|
+
/** 是否有任何已装配内容(已锁存段或当前模型文本非空)。 */
|
|
49
|
+
hasContent(): boolean;
|
|
50
|
+
/** 新 turn 清空(dispatcher 创建时调用;终帧发出后也应复位)。 */
|
|
51
|
+
reset(): void;
|
|
52
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Stream assembler — 对话管理层的流式文本装配器。
|
|
2
|
+
//
|
|
3
|
+
// 收拢原 reply-dispatcher 闭包中的 prevModelText/currentModelText 拼接逻辑,
|
|
4
|
+
// 升级为有序段模型:
|
|
5
|
+
// - model 段:一次模型调用的完整文本(onStreamText 跨调用边界时锁存)
|
|
6
|
+
// - injected 段:工具注入的非模型文本(如 display-a2ui-card 的卡片 DSL)
|
|
7
|
+
//
|
|
8
|
+
// 设计要点:
|
|
9
|
+
// - 段以 "\n" 连接,轮间换行符天然保留(锁存不带分隔符的 bug 在结构上消除)。
|
|
10
|
+
// - 权威文本修正:openclaw 在 message_end 发出的最终清洗文本不走
|
|
11
|
+
// onPartialReply,流式累计可能缺尾;deliver(kind=final) 提供 canonical
|
|
12
|
+
// 文本,finalize() 时以它替换当前轮(或最后一个 model 段)。
|
|
13
|
+
// - 本类是纯逻辑,不做任何发送;发送由调用方经 outbound-queue 完成。
|
|
14
|
+
const SEGMENT_SEPARATOR = "\n";
|
|
15
|
+
export class StreamAssembler {
|
|
16
|
+
segments = [];
|
|
17
|
+
/** 当前模型调用的流式累计文本(同一调用内递增)。 */
|
|
18
|
+
currentModelText = "";
|
|
19
|
+
/** deliver(kind=final) 捕获的权威最终文本(最后一次模型调用的 canonical 文本)。 */
|
|
20
|
+
canonicalFinalText = "";
|
|
21
|
+
/**
|
|
22
|
+
* onPartialReply 喂入。text 是当前模型调用的累计清洗文本(非增量)。
|
|
23
|
+
* 同一调用内 text 递增;跨调用时 text 刷新(!startsWith),此时把上一个
|
|
24
|
+
* 调用的完整文本锁存为 model 段。
|
|
25
|
+
*/
|
|
26
|
+
onStreamText(text) {
|
|
27
|
+
let latched = false;
|
|
28
|
+
if (this.currentModelText && !text.startsWith(this.currentModelText)) {
|
|
29
|
+
this.segments.push({ kind: "model", text: this.currentModelText });
|
|
30
|
+
latched = true;
|
|
31
|
+
}
|
|
32
|
+
this.currentModelText = text;
|
|
33
|
+
return { fullText: this.getFullText(), latched };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 工具注入非模型文本(如卡片 DSL)。先锁存当前模型文本(保持相对位置),
|
|
37
|
+
* 再把注入内容作为独立段压入。注入后所有全量帧自然携带该段,
|
|
38
|
+
* 客户端 append:false 整体替换不会抹掉注入内容。
|
|
39
|
+
*/
|
|
40
|
+
injectArtifact(text) {
|
|
41
|
+
if (this.currentModelText) {
|
|
42
|
+
this.segments.push({ kind: "model", text: this.currentModelText });
|
|
43
|
+
this.currentModelText = "";
|
|
44
|
+
}
|
|
45
|
+
this.segments.push({ kind: "injected", text });
|
|
46
|
+
return this.getFullText();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* deliver(kind=final) 喂入权威文本。调用方必须已排除 compaction 通知和
|
|
50
|
+
* ⚙️ 系统通知(它们不是答案文本)。
|
|
51
|
+
*/
|
|
52
|
+
onFinalText(text) {
|
|
53
|
+
this.canonicalFinalText = text;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* onIdle 收口:计算终帧全文本。
|
|
57
|
+
* canonical 非空时替换 currentModelText;current 为空(已锁存/无流式)时
|
|
58
|
+
* 替换最后一个 model 段;完全没有 model 内容(ACP 纯 deliver 路径)时
|
|
59
|
+
* fullText 就是 canonical 本身。
|
|
60
|
+
*/
|
|
61
|
+
finalize() {
|
|
62
|
+
let resolvedLastText = this.currentModelText;
|
|
63
|
+
let diagnostic = "no-canonical";
|
|
64
|
+
const canonical = this.canonicalFinalText;
|
|
65
|
+
if (canonical) {
|
|
66
|
+
if (canonical === this.currentModelText) {
|
|
67
|
+
diagnostic = "match";
|
|
68
|
+
}
|
|
69
|
+
else if (canonical.startsWith(this.currentModelText)) {
|
|
70
|
+
diagnostic = "patched-tail";
|
|
71
|
+
}
|
|
72
|
+
else if (this.currentModelText.startsWith(canonical)) {
|
|
73
|
+
diagnostic = "canonical-shorter";
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
diagnostic = "diverged";
|
|
77
|
+
}
|
|
78
|
+
resolvedLastText = canonical;
|
|
79
|
+
if (this.currentModelText) {
|
|
80
|
+
this.currentModelText = canonical;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// current 已锁存或从未流式:替换最后一个 model 段;无 model 段则
|
|
84
|
+
// canonical 自成一段(ACP 纯 deliver 路径)。
|
|
85
|
+
for (let i = this.segments.length - 1; i >= 0; i--) {
|
|
86
|
+
if (this.segments[i].kind === "model") {
|
|
87
|
+
this.segments[i] = { kind: "model", text: canonical };
|
|
88
|
+
return {
|
|
89
|
+
fullText: this.getFullText(),
|
|
90
|
+
resolvedLastText,
|
|
91
|
+
diagnostic,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
this.segments.push({ kind: "model", text: canonical });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
fullText: this.getFullText(),
|
|
100
|
+
resolvedLastText,
|
|
101
|
+
diagnostic,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** 全文本:已锁存段以 "\n" 连接,再接当前模型文本。 */
|
|
105
|
+
getFullText() {
|
|
106
|
+
const parts = this.segments.map((s) => s.text);
|
|
107
|
+
if (this.currentModelText) {
|
|
108
|
+
parts.push(this.currentModelText);
|
|
109
|
+
}
|
|
110
|
+
return parts.join(SEGMENT_SEPARATOR);
|
|
111
|
+
}
|
|
112
|
+
/** 是否有任何已装配内容(已锁存段或当前模型文本非空)。 */
|
|
113
|
+
hasContent() {
|
|
114
|
+
return this.segments.length > 0 || this.currentModelText.length > 0;
|
|
115
|
+
}
|
|
116
|
+
/** 新 turn 清空(dispatcher 创建时调用;终帧发出后也应复位)。 */
|
|
117
|
+
reset() {
|
|
118
|
+
this.segments = [];
|
|
119
|
+
this.currentModelText = "";
|
|
120
|
+
this.canonicalFinalText = "";
|
|
121
|
+
}
|
|
122
|
+
}
|