@wu529778790/open-im 1.11.8-beta.2 → 1.11.8-beta.21
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/adapters/codebuddy-adapter.js +3 -17
- package/dist/adapters/codex-adapter.js +3 -16
- package/dist/adapters/opencode-adapter.d.ts +0 -5
- package/dist/adapters/opencode-adapter.js +10 -25
- package/dist/adapters/registry.js +10 -1
- package/dist/clawbot/client.js +72 -78
- package/dist/clawbot/qr-login.d.ts +1 -0
- package/dist/clawbot/qr-login.js +7 -0
- package/dist/codebuddy/cli-runner.d.ts +2 -22
- package/dist/codebuddy/cli-runner.js +9 -27
- package/dist/codex/cli-runner.d.ts +2 -22
- package/dist/codex/cli-runner.js +24 -75
- package/dist/commands/handler.d.ts +1 -0
- package/dist/commands/handler.js +34 -0
- package/dist/config/types.d.ts +23 -0
- package/dist/config-web-auth.d.ts +13 -0
- package/dist/config-web-auth.js +114 -0
- package/dist/config-web-browser.d.ts +4 -0
- package/dist/config-web-browser.js +48 -0
- package/dist/config-web-cors.d.ts +5 -0
- package/dist/config-web-cors.js +48 -0
- package/dist/config-web-health.d.ts +7 -0
- package/dist/config-web-health.js +65 -0
- package/dist/config-web-http.d.ts +3 -0
- package/dist/config-web-http.js +31 -0
- package/dist/config-web-page-i18n.d.ts +36 -2
- package/dist/config-web-page-i18n.js +36 -2
- package/dist/config-web-payload.d.ts +84 -0
- package/dist/config-web-payload.js +260 -0
- package/dist/config-web-probes.d.ts +2 -0
- package/dist/config-web-probes.js +271 -0
- package/dist/config-web.d.ts +3 -11
- package/dist/config-web.js +45 -815
- package/dist/config.js +22 -1
- package/dist/constants.d.ts +10 -0
- package/dist/constants.js +11 -0
- package/dist/opencode/cli-runner.d.ts +2 -22
- package/dist/opencode/cli-runner.js +47 -68
- package/dist/opencode/sdk-manager.d.ts +11 -0
- package/dist/opencode/sdk-manager.js +46 -0
- package/dist/opencode/sdk-runner.d.ts +7 -0
- package/dist/opencode/sdk-runner.js +225 -0
- package/dist/platform/handle-ai-request.js +18 -1
- package/dist/qq/client.js +45 -53
- package/dist/shared/ai-task.d.ts +15 -0
- package/dist/shared/ai-task.js +174 -2
- package/dist/shared/cli-runner-base.d.ts +39 -0
- package/dist/shared/cli-runner-base.js +108 -0
- package/dist/shared/connection-manager.d.ts +111 -0
- package/dist/shared/connection-manager.js +157 -0
- package/dist/shared/session-invalid-detector.d.ts +5 -0
- package/dist/shared/session-invalid-detector.js +20 -0
- package/dist/wework/client.js +79 -127
- package/dist/workbuddy/client.js +40 -74
- package/package.json +4 -1
- package/web/dist/assets/index-6e51Wtaa.css +1 -0
- package/web/dist/assets/index-Da9qKWA2.js +57 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-XKVTb-0p.css +0 -1
- package/web/dist/assets/index-yDVGC_84.js +0 -57
package/dist/qq/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import { createLogger } from "../logger.js";
|
|
3
|
-
import {
|
|
3
|
+
import { ReconnectManager, HeartbeatMonitor } from "../shared/connection-manager.js";
|
|
4
4
|
const log = createLogger("QQ");
|
|
5
5
|
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
|
6
6
|
const API_BASE = "https://api.sgroup.qq.com";
|
|
@@ -12,25 +12,23 @@ const INTENTS = {
|
|
|
12
12
|
};
|
|
13
13
|
let client = null;
|
|
14
14
|
let ws = null;
|
|
15
|
-
let heartbeatTimer = null;
|
|
16
|
-
let reconnectTimer = null;
|
|
17
15
|
let stopped = false;
|
|
18
16
|
let seq = null;
|
|
19
17
|
let sessionId = null;
|
|
20
|
-
let reconnectAttempt = 0;
|
|
21
18
|
let connecting = false; // 防止并发 connectWebSocket
|
|
22
19
|
let currentConfig = null;
|
|
23
20
|
let currentHandler = null;
|
|
24
21
|
let tokenState = null;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
// Connection infrastructure (shared managers replace raw timers/counters)
|
|
23
|
+
const heartbeatMonitor = new HeartbeatMonitor();
|
|
24
|
+
const reconnectManager = new ReconnectManager({
|
|
25
|
+
name: 'QQ',
|
|
26
|
+
backoff: { mode: 'stepped', delays: RECONNECT_DELAYS_MS },
|
|
27
|
+
onReconnect: () => reconnectFn(),
|
|
28
|
+
});
|
|
29
|
+
async function reconnectFn() {
|
|
30
|
+
if (currentConfig && currentHandler) {
|
|
31
|
+
await connectWebSocket(currentConfig, currentHandler);
|
|
34
32
|
}
|
|
35
33
|
}
|
|
36
34
|
function buildAuthHeaders(token) {
|
|
@@ -143,28 +141,28 @@ function normalizeInboundEvent(payload) {
|
|
|
143
141
|
}
|
|
144
142
|
return null;
|
|
145
143
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
144
|
+
/**
|
|
145
|
+
* Heartbeat tick: send ping and detect stale connections.
|
|
146
|
+
* The heartbeat interval is dynamic (from QQ gateway HELLO message).
|
|
147
|
+
*/
|
|
148
|
+
let heartbeatIntervalMs = 30000;
|
|
149
|
+
function heartbeatTick() {
|
|
150
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
151
|
+
return;
|
|
152
|
+
if (heartbeatMonitor.isStale(heartbeatIntervalMs * 3)) {
|
|
153
|
+
const elapsed = Date.now() - heartbeatMonitor.lastResponseTime;
|
|
154
|
+
log.warn(`QQ dead connection: no response for ${Math.round(elapsed / 1000)}s, reconnecting`);
|
|
155
|
+
heartbeatMonitor.stop();
|
|
156
|
+
ws?.terminate();
|
|
157
|
+
reconnectFn().catch((err) => log.error('QQ reconnect from heartbeat failed:', err));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
ws.send(JSON.stringify({ op: 1, d: seq }));
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
log.warn('QQ heartbeat send failed:', err);
|
|
165
|
+
}
|
|
168
166
|
}
|
|
169
167
|
async function connectWebSocket(config, handler) {
|
|
170
168
|
// 防止并发连接
|
|
@@ -196,17 +194,17 @@ async function connectWebSocket(config, handler) {
|
|
|
196
194
|
};
|
|
197
195
|
socket.on("open", () => {
|
|
198
196
|
log.info("QQ gateway connected");
|
|
199
|
-
|
|
197
|
+
reconnectManager.reset();
|
|
200
198
|
});
|
|
201
199
|
socket.on("message", async (raw) => {
|
|
202
|
-
|
|
200
|
+
heartbeatMonitor.recordResponse();
|
|
203
201
|
try {
|
|
204
202
|
const payload = JSON.parse(raw.toString());
|
|
205
203
|
if (typeof payload.s === "number")
|
|
206
204
|
seq = payload.s;
|
|
207
205
|
if (payload.op === 10) {
|
|
208
|
-
|
|
209
|
-
|
|
206
|
+
heartbeatIntervalMs = Number(payload.d?.heartbeat_interval ?? 30000);
|
|
207
|
+
heartbeatMonitor.start(heartbeatIntervalMs, heartbeatTick);
|
|
210
208
|
socket.send(JSON.stringify({
|
|
211
209
|
op: sessionId ? 6 : 2,
|
|
212
210
|
d: sessionId
|
|
@@ -253,7 +251,7 @@ async function connectWebSocket(config, handler) {
|
|
|
253
251
|
});
|
|
254
252
|
socket.on("close", (code, reason) => {
|
|
255
253
|
settle(() => { }); // 清理 ready timeout
|
|
256
|
-
|
|
254
|
+
heartbeatMonitor.stop();
|
|
257
255
|
ws = null;
|
|
258
256
|
const reasonStr = reason.toString();
|
|
259
257
|
if (code === 4009) {
|
|
@@ -273,19 +271,11 @@ async function connectWebSocket(config, handler) {
|
|
|
273
271
|
seq = null;
|
|
274
272
|
}
|
|
275
273
|
const isFatalClose = code === 4004 || code === 4006 || code === 4007;
|
|
276
|
-
const baseDelay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
277
|
-
const delay = isFatalClose ? jitteredDelay(SLOW_PROBE_MS) : jitteredDelay(baseDelay);
|
|
278
274
|
if (isFatalClose) {
|
|
279
|
-
|
|
275
|
+
reconnectManager.setFatal(true);
|
|
276
|
+
log.warn(`QQ 致命关闭码 ${code}(鉴权失败),转慢探测(每 ${Math.round(5 * 60)}s 一次)`);
|
|
280
277
|
}
|
|
281
|
-
|
|
282
|
-
reconnectTimer = setTimeout(() => {
|
|
283
|
-
if (currentConfig && currentHandler) {
|
|
284
|
-
connectWebSocket(currentConfig, currentHandler).catch((err) => {
|
|
285
|
-
log.error("QQ reconnect failed:", err);
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
}, delay);
|
|
278
|
+
reconnectManager.schedule();
|
|
289
279
|
});
|
|
290
280
|
});
|
|
291
281
|
}
|
|
@@ -306,6 +296,8 @@ export async function initQQ(config, eventHandler) {
|
|
|
306
296
|
stopped = false;
|
|
307
297
|
currentConfig = config;
|
|
308
298
|
currentHandler = eventHandler;
|
|
299
|
+
reconnectManager.reset();
|
|
300
|
+
reconnectManager.resume();
|
|
309
301
|
client = {
|
|
310
302
|
sendPrivateMessage: async (openid, content, replyToMessageId) => {
|
|
311
303
|
const res = await apiRequest(config, "POST", `/v2/users/${openid}/messages`, buildMessageBody(content, replyToMessageId));
|
|
@@ -328,7 +320,8 @@ export async function initQQ(config, eventHandler) {
|
|
|
328
320
|
}
|
|
329
321
|
export async function stopQQ() {
|
|
330
322
|
stopped = true;
|
|
331
|
-
|
|
323
|
+
heartbeatMonitor.stop();
|
|
324
|
+
reconnectManager.stop();
|
|
332
325
|
if (ws) {
|
|
333
326
|
ws.close(1000);
|
|
334
327
|
ws = null;
|
|
@@ -339,5 +332,4 @@ export async function stopQQ() {
|
|
|
339
332
|
tokenState = null;
|
|
340
333
|
sessionId = null;
|
|
341
334
|
seq = null;
|
|
342
|
-
lastServerResponseTime = 0;
|
|
343
335
|
}
|
package/dist/shared/ai-task.d.ts
CHANGED
|
@@ -4,9 +4,24 @@
|
|
|
4
4
|
import type { Config } from '../config.js';
|
|
5
5
|
import type { SessionManager } from '../session/session-manager.js';
|
|
6
6
|
import type { ToolAdapter } from '../adapters/tool-adapter.interface.js';
|
|
7
|
+
/** 查询指定用户的 autopilot 等待状态(供 /autopilot 命令使用)。 */
|
|
8
|
+
export declare function getAutopilotPendingStatus(userId: string): {
|
|
9
|
+
retryAt: Date;
|
|
10
|
+
type: string;
|
|
11
|
+
retryCount: number;
|
|
12
|
+
} | undefined;
|
|
13
|
+
/** 清除指定用户的 autopilot 状态(测试用)。 */
|
|
14
|
+
export declare function clearAutopilotState(userId: string): void;
|
|
7
15
|
export interface TaskDeps {
|
|
8
16
|
config: Config;
|
|
9
17
|
sessionManager: SessionManager;
|
|
18
|
+
/**
|
|
19
|
+
* Autopilot 回调:限流恢复后调用,将 autoResumePrompt 作为新消息
|
|
20
|
+
* 通过平台的请求队列重新入队。如果不提供,autopilot 不执行恢复动作。
|
|
21
|
+
*/
|
|
22
|
+
autopilot?: {
|
|
23
|
+
onAutoPilotContinue: (prompt: string) => void;
|
|
24
|
+
};
|
|
10
25
|
}
|
|
11
26
|
export interface TaskContext {
|
|
12
27
|
userId: string;
|
package/dist/shared/ai-task.js
CHANGED
|
@@ -6,8 +6,99 @@ import { captureError } from './sentry.js';
|
|
|
6
6
|
import { formatToolStats, formatToolCallNotification, getContextWarning, getAIToolDisplayName, toReplyPlainText, } from './utils.js';
|
|
7
7
|
import { createLogger } from '../logger.js';
|
|
8
8
|
const log = createLogger('AITask');
|
|
9
|
+
/** 每用户连续 autopilot 重试计数。成功完成或遇到非限流错误时清零。 */
|
|
10
|
+
const autopilotRetryCount = new Map();
|
|
11
|
+
/** 当前等待中的 autopilot 定时器(用于 /autopilot 状态查询)。 */
|
|
12
|
+
const pendingAutopilotTimers = new Map();
|
|
13
|
+
/** 查询指定用户的 autopilot 等待状态(供 /autopilot 命令使用)。 */
|
|
14
|
+
export function getAutopilotPendingStatus(userId) {
|
|
15
|
+
return pendingAutopilotTimers.get(userId);
|
|
16
|
+
}
|
|
17
|
+
/** 清除指定用户的 autopilot 状态(测试用)。 */
|
|
18
|
+
export function clearAutopilotState(userId) {
|
|
19
|
+
autopilotRetryCount.delete(userId);
|
|
20
|
+
pendingAutopilotTimers.delete(userId);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 判断错误是否为限流类错误(用于决定是否保留 session)。
|
|
24
|
+
* 覆盖所有已知的限流模式:429/529/session limit/overloaded/temporary throttle。
|
|
25
|
+
*/
|
|
9
26
|
function isUsageLimitError(error) {
|
|
10
|
-
return /usage
|
|
27
|
+
return /usage\s*limit/i.test(error)
|
|
28
|
+
|| /try\s+again\s+at\s+\d{1,2}:\d{2}/i.test(error)
|
|
29
|
+
|| /rate\s*limit/i.test(error)
|
|
30
|
+
|| /\b429\b/.test(error)
|
|
31
|
+
|| /\b529\b/.test(error)
|
|
32
|
+
|| /overloaded/i.test(error)
|
|
33
|
+
|| /temporarily\s+limiting/i.test(error)
|
|
34
|
+
|| /session\s+limit/i.test(error)
|
|
35
|
+
|| /you['\u2019]ve\s+hit\s+your/i.test(error);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 从错误消息中提取限流详情。仅在 isUsageLimitError() 返回 true 后调用。
|
|
39
|
+
*/
|
|
40
|
+
function classifyRateLimit(error, config) {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const shortMs = config.autopilot.shortRetrySeconds * 1000;
|
|
43
|
+
const defaultMs = config.autopilot.defaultIntervalHours * 3600 * 1000;
|
|
44
|
+
// Session quota / usage limit(最常见,等待时间最长)
|
|
45
|
+
if (/session\s*limit|usage\s*limit|opus\s*limit|you['\u2019]ve\s+hit\s+your/i.test(error)) {
|
|
46
|
+
const timeMatch = error.match(/try\s+again\s+at\s+(\d{1,2}):(\d{2})\s*(AM|PM)?/i);
|
|
47
|
+
if (timeMatch) {
|
|
48
|
+
let hours = parseInt(timeMatch[1], 10);
|
|
49
|
+
const minutes = parseInt(timeMatch[2], 10);
|
|
50
|
+
const ampm = timeMatch[3]?.toUpperCase();
|
|
51
|
+
if (ampm === 'PM' && hours < 12)
|
|
52
|
+
hours += 12;
|
|
53
|
+
if (ampm === 'AM' && hours === 12)
|
|
54
|
+
hours = 0;
|
|
55
|
+
const retryAt = new Date();
|
|
56
|
+
retryAt.setHours(hours, minutes, 0, 0);
|
|
57
|
+
if (retryAt.getTime() <= now)
|
|
58
|
+
retryAt.setDate(retryAt.getDate() + 1);
|
|
59
|
+
return { detected: true, type: 'session_limit', retryAt, isLongWait: true };
|
|
60
|
+
}
|
|
61
|
+
return { detected: true, type: 'session_limit', retryAt: new Date(now + defaultMs), isLongWait: true };
|
|
62
|
+
}
|
|
63
|
+
// 529 overloaded / temporarily limiting(短延迟)
|
|
64
|
+
if (/\b529\b|overloaded|temporarily\s+limiting/i.test(error)) {
|
|
65
|
+
return {
|
|
66
|
+
detected: true,
|
|
67
|
+
type: /overloaded/i.test(error) ? 'overloaded' : 'temporary',
|
|
68
|
+
retryAt: new Date(now + shortMs),
|
|
69
|
+
isLongWait: false,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// 429 rate limit(使用默认周期)
|
|
73
|
+
if (/\b429\b|rate\s*limit/i.test(error)) {
|
|
74
|
+
return { detected: true, type: 'api_rate_limit', retryAt: new Date(now + defaultMs), isLongWait: true };
|
|
75
|
+
}
|
|
76
|
+
return { detected: false, type: null, retryAt: null, isLongWait: false };
|
|
77
|
+
}
|
|
78
|
+
function rateLimitTypeLabel(type) {
|
|
79
|
+
switch (type) {
|
|
80
|
+
case 'session_limit': return '会话额度限制';
|
|
81
|
+
case 'api_rate_limit': return 'API 速率限制';
|
|
82
|
+
case 'overloaded': return '服务器过载';
|
|
83
|
+
case 'temporary': return '临时限流';
|
|
84
|
+
default: return '限流';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function formatTimeHHMM(date) {
|
|
88
|
+
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
|
89
|
+
}
|
|
90
|
+
function formatDuration(ms) {
|
|
91
|
+
if (ms <= 0)
|
|
92
|
+
return '即将';
|
|
93
|
+
const totalSec = Math.ceil(ms / 1000);
|
|
94
|
+
const hours = Math.floor(totalSec / 3600);
|
|
95
|
+
const minutes = Math.floor((totalSec % 3600) / 60);
|
|
96
|
+
const seconds = totalSec % 60;
|
|
97
|
+
if (hours > 0)
|
|
98
|
+
return `${hours}小时${minutes > 0 ? `${minutes}分钟` : ''}`;
|
|
99
|
+
if (minutes > 0)
|
|
100
|
+
return `${minutes}分钟${seconds > 0 ? `${seconds}秒` : ''}`;
|
|
101
|
+
return `${seconds}秒`;
|
|
11
102
|
}
|
|
12
103
|
/**
|
|
13
104
|
* 将 AI/CLI 错误文本归类为一个稳定的 errorType 字符串,用于遥测聚合。
|
|
@@ -211,6 +302,8 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
|
|
|
211
302
|
if (settled)
|
|
212
303
|
return;
|
|
213
304
|
settled = true;
|
|
305
|
+
// 成功完成 → 清除 autopilot 重试计数
|
|
306
|
+
autopilotRetryCount.delete(ctx.userId);
|
|
214
307
|
if (pendingUpdate) {
|
|
215
308
|
clearTimeout(pendingUpdate);
|
|
216
309
|
pendingUpdate = null;
|
|
@@ -263,6 +356,83 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
|
|
|
263
356
|
}
|
|
264
357
|
settled = true;
|
|
265
358
|
log.error(`Task error for user ${ctx.userId}: ${error}`);
|
|
359
|
+
// ── Autopilot 拦截 ──
|
|
360
|
+
const apConfig = config.autopilot;
|
|
361
|
+
const currentRetries = autopilotRetryCount.get(ctx.userId) ?? 0;
|
|
362
|
+
if (apConfig?.enabled &&
|
|
363
|
+
deps.autopilot &&
|
|
364
|
+
isUsageLimitError(error) &&
|
|
365
|
+
currentRetries < apConfig.maxRetries) {
|
|
366
|
+
const rateInfo = classifyRateLimit(error, config);
|
|
367
|
+
if (rateInfo.detected && rateInfo.retryAt) {
|
|
368
|
+
const retryCount = currentRetries + 1;
|
|
369
|
+
autopilotRetryCount.set(ctx.userId, retryCount);
|
|
370
|
+
// 记录等待状态
|
|
371
|
+
pendingAutopilotTimers.set(ctx.userId, {
|
|
372
|
+
retryAt: rateInfo.retryAt,
|
|
373
|
+
type: rateInfo.type,
|
|
374
|
+
retryCount,
|
|
375
|
+
});
|
|
376
|
+
// 通知用户
|
|
377
|
+
const typeLabel = rateLimitTypeLabel(rateInfo.type);
|
|
378
|
+
const timeStr = formatTimeHHMM(rateInfo.retryAt);
|
|
379
|
+
const remaining = formatDuration(rateInfo.retryAt.getTime() - Date.now());
|
|
380
|
+
const statusMsg = `⏳ 检测到${typeLabel},将在 ${timeStr}(${remaining}后)自动恢复 (${retryCount}/${apConfig.maxRetries})`;
|
|
381
|
+
log.info(`[Autopilot] ${statusMsg} for user ${ctx.userId}`);
|
|
382
|
+
try {
|
|
383
|
+
await platformAdapter.sendError(hadSessionInvalid
|
|
384
|
+
? '当前 Claude 会话已失效,已自动执行 /new 重置会话,请重新发送刚才的问题。'
|
|
385
|
+
: `${error}\n\n${statusMsg}`);
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
log.error('Failed to send autopilot status:', err);
|
|
389
|
+
}
|
|
390
|
+
cleanup();
|
|
391
|
+
resolve();
|
|
392
|
+
// 异步等待 → 恢复(不阻塞 Promise 解决)
|
|
393
|
+
const retryAt = rateInfo.retryAt;
|
|
394
|
+
const delayMs = Math.max(retryAt.getTime() - Date.now(), 0);
|
|
395
|
+
log.info(`[Autopilot] Scheduling retry in ${delayMs}ms for user ${ctx.userId}`);
|
|
396
|
+
// 分段定时器(setTimeout 最大 ~24.8 天)
|
|
397
|
+
const MAX_SEGMENT = 2_147_483_647; // 2^31 - 1
|
|
398
|
+
const scheduleRetry = (remaining) => {
|
|
399
|
+
if (remaining <= 0) {
|
|
400
|
+
// 定时器到期 → 清除状态 → 发送恢复消息
|
|
401
|
+
pendingAutopilotTimers.delete(ctx.userId);
|
|
402
|
+
log.info(`[Autopilot] Timer fired for user ${ctx.userId}, sending "${apConfig.autoResumePrompt}"`);
|
|
403
|
+
try {
|
|
404
|
+
platformAdapter.streamUpdate('', `🔄 限额已恢复,正在自动重试... (${retryCount})`);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
/* ignore */
|
|
408
|
+
}
|
|
409
|
+
deps.autopilot.onAutoPilotContinue(apConfig.autoResumePrompt);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const segment = Math.min(remaining, MAX_SEGMENT);
|
|
413
|
+
const timer = setTimeout(() => scheduleRetry(remaining - segment), segment);
|
|
414
|
+
if (typeof timer === 'object' && 'unref' in timer)
|
|
415
|
+
timer.unref();
|
|
416
|
+
// 如果任务被 abort(/new、cancelUser),取消待执行的定时器
|
|
417
|
+
if (ctx.signal) {
|
|
418
|
+
if (ctx.signal.aborted) {
|
|
419
|
+
clearTimeout(timer);
|
|
420
|
+
pendingAutopilotTimers.delete(ctx.userId);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
ctx.signal.addEventListener('abort', () => {
|
|
424
|
+
clearTimeout(timer);
|
|
425
|
+
pendingAutopilotTimers.delete(ctx.userId);
|
|
426
|
+
log.info(`[Autopilot] Timer cancelled by abort for user ${ctx.userId}`);
|
|
427
|
+
}, { once: true });
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
scheduleRetry(delayMs);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// ── 非限流错误或 autopilot 未启用/耗尽重试 ──
|
|
435
|
+
autopilotRetryCount.delete(ctx.userId);
|
|
266
436
|
if (isUsageLimitError(error)) {
|
|
267
437
|
// Usage limit errors: keep session for all tools (user can retry later)
|
|
268
438
|
log.warn(`Keeping ${aiCommand} session for user ${ctx.userId} after usage limit error`);
|
|
@@ -290,7 +460,9 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
|
|
|
290
460
|
}, {
|
|
291
461
|
model: aiCommand === 'claude'
|
|
292
462
|
? (sessionManager.getModel(ctx.userId, ctx.threadId) ?? config.claudeModel)
|
|
293
|
-
:
|
|
463
|
+
: aiCommand === 'opencode'
|
|
464
|
+
? config.opencodeModel
|
|
465
|
+
: undefined,
|
|
294
466
|
chatId: ctx.chatId,
|
|
295
467
|
// 默认跳过权限确认,保持全自动执行(可通过 config 或环境变量关闭)
|
|
296
468
|
skipPermissions: config.skipPermissions ?? true,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CLI runner utilities — stderr buffering, finalize gating,
|
|
3
|
+
* wall-clock timeout, and abort handle.
|
|
4
|
+
*
|
|
5
|
+
* Used by codex, codebuddy, and opencode CLI runners to eliminate
|
|
6
|
+
* duplicated process lifecycle management code.
|
|
7
|
+
*/
|
|
8
|
+
import { type ChildProcess, type SpawnOptions } from 'node:child_process';
|
|
9
|
+
import type { Interface as ReadlineInterface } from 'node:readline';
|
|
10
|
+
import type { RunHandle } from '../adapters/tool-adapter.interface.js';
|
|
11
|
+
export declare const MAX_STDERR_HEAD: number;
|
|
12
|
+
export declare const MAX_STDERR_TAIL: number;
|
|
13
|
+
export interface StderrBuffer {
|
|
14
|
+
head: string;
|
|
15
|
+
tail: string;
|
|
16
|
+
total: number;
|
|
17
|
+
headFull: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function createStderrBuffer(): StderrBuffer;
|
|
20
|
+
export declare function appendStderr(buf: StderrBuffer, text: string): void;
|
|
21
|
+
export declare function reconstructStderr(buf: StderrBuffer): string;
|
|
22
|
+
export interface FinalizeGate {
|
|
23
|
+
rlClosed: boolean;
|
|
24
|
+
childClosed: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function createFinalizeGate(): FinalizeGate;
|
|
27
|
+
export declare function isFinalizeReady(gate: FinalizeGate): boolean;
|
|
28
|
+
export declare function startWallClockTimeout(child: ChildProcess, timeoutMs: number, toolName: string, log: {
|
|
29
|
+
warn: (...args: unknown[]) => void;
|
|
30
|
+
}, onTimeout: () => void, extraCleanup?: () => void): ReturnType<typeof setTimeout>;
|
|
31
|
+
export declare function clearWallClockTimeout(handle: ReturnType<typeof setTimeout> | null): void;
|
|
32
|
+
export declare function createAbortHandle(completed: {
|
|
33
|
+
value: boolean;
|
|
34
|
+
}, child: ChildProcess, timeoutHandle: {
|
|
35
|
+
value: ReturnType<typeof setTimeout> | null;
|
|
36
|
+
}, rl?: ReadlineInterface): RunHandle;
|
|
37
|
+
export declare function buildBaseEnv(extra?: Record<string, string | undefined>): NodeJS.ProcessEnv;
|
|
38
|
+
export declare function buildBaseSpawnOptions(workDir: string, stdio?: ('pipe' | 'ignore' | 'inherit')[], extraEnv?: Record<string, string | undefined>): SpawnOptions;
|
|
39
|
+
export declare function spawnCliProcess(command: string, args: string[], workDir: string, stdio?: ('pipe' | 'ignore' | 'inherit')[], extraEnv?: Record<string, string | undefined>): ChildProcess;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CLI runner utilities — stderr buffering, finalize gating,
|
|
3
|
+
* wall-clock timeout, and abort handle.
|
|
4
|
+
*
|
|
5
|
+
* Used by codex, codebuddy, and opencode CLI runners to eliminate
|
|
6
|
+
* duplicated process lifecycle management code.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { processEnvForNonClaudeCliChild } from '../config/file-io.js';
|
|
10
|
+
import { killProcessTree, trackChild } from './process-kill.js';
|
|
11
|
+
// ── stderr head/tail buffer ──────────────────────────────────────
|
|
12
|
+
export const MAX_STDERR_HEAD = 4 * 1024;
|
|
13
|
+
export const MAX_STDERR_TAIL = 6 * 1024;
|
|
14
|
+
export function createStderrBuffer() {
|
|
15
|
+
return { head: '', tail: '', total: 0, headFull: false };
|
|
16
|
+
}
|
|
17
|
+
export function appendStderr(buf, text) {
|
|
18
|
+
buf.total += text.length;
|
|
19
|
+
if (!buf.headFull) {
|
|
20
|
+
const room = MAX_STDERR_HEAD - buf.head.length;
|
|
21
|
+
if (room > 0) {
|
|
22
|
+
buf.head += text.slice(0, room);
|
|
23
|
+
if (buf.head.length >= MAX_STDERR_HEAD)
|
|
24
|
+
buf.headFull = true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
buf.tail += text;
|
|
28
|
+
if (buf.tail.length > MAX_STDERR_TAIL) {
|
|
29
|
+
buf.tail = buf.tail.slice(-MAX_STDERR_TAIL);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function reconstructStderr(buf) {
|
|
33
|
+
if (buf.total === 0)
|
|
34
|
+
return '';
|
|
35
|
+
if (!buf.headFull)
|
|
36
|
+
return buf.head;
|
|
37
|
+
if (buf.total <= MAX_STDERR_HEAD + MAX_STDERR_TAIL) {
|
|
38
|
+
return buf.head + buf.tail.slice(buf.tail.length - (buf.total - MAX_STDERR_HEAD));
|
|
39
|
+
}
|
|
40
|
+
return (buf.head +
|
|
41
|
+
`\n\n... (omitted ${buf.total - MAX_STDERR_HEAD - MAX_STDERR_TAIL} bytes) ...\n\n` +
|
|
42
|
+
buf.tail);
|
|
43
|
+
}
|
|
44
|
+
export function createFinalizeGate() {
|
|
45
|
+
return { rlClosed: false, childClosed: false };
|
|
46
|
+
}
|
|
47
|
+
export function isFinalizeReady(gate) {
|
|
48
|
+
return gate.rlClosed && gate.childClosed;
|
|
49
|
+
}
|
|
50
|
+
// ── wall-clock timeout ───────────────────────────────────────────
|
|
51
|
+
export function startWallClockTimeout(child, timeoutMs, toolName, log, onTimeout, extraCleanup) {
|
|
52
|
+
const handle = setTimeout(() => {
|
|
53
|
+
log.warn(`${toolName} CLI 超过 ${timeoutMs}ms,强制终止 (pid=${child.pid})`);
|
|
54
|
+
extraCleanup?.();
|
|
55
|
+
onTimeout();
|
|
56
|
+
killProcessTree(child);
|
|
57
|
+
}, timeoutMs);
|
|
58
|
+
handle.unref();
|
|
59
|
+
return handle;
|
|
60
|
+
}
|
|
61
|
+
export function clearWallClockTimeout(handle) {
|
|
62
|
+
if (handle)
|
|
63
|
+
clearTimeout(handle);
|
|
64
|
+
}
|
|
65
|
+
// ── abort handle ─────────────────────────────────────────────────
|
|
66
|
+
export function createAbortHandle(completed, child, timeoutHandle, rl) {
|
|
67
|
+
return {
|
|
68
|
+
abort: () => {
|
|
69
|
+
if (completed.value)
|
|
70
|
+
return;
|
|
71
|
+
completed.value = true;
|
|
72
|
+
clearWallClockTimeout(timeoutHandle.value);
|
|
73
|
+
timeoutHandle.value = null;
|
|
74
|
+
rl?.close();
|
|
75
|
+
killProcessTree(child);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// ── environment and spawn configuration ─────────────────────────
|
|
80
|
+
export function buildBaseEnv(extra) {
|
|
81
|
+
const env = processEnvForNonClaudeCliChild();
|
|
82
|
+
if (process.platform === 'win32') {
|
|
83
|
+
env.LANG = env.LANG || 'C.UTF-8';
|
|
84
|
+
env.LC_ALL = env.LC_ALL || 'C.UTF-8';
|
|
85
|
+
}
|
|
86
|
+
if (extra) {
|
|
87
|
+
for (const [key, value] of Object.entries(extra)) {
|
|
88
|
+
if (value !== undefined)
|
|
89
|
+
env[key] = value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return env;
|
|
93
|
+
}
|
|
94
|
+
export function buildBaseSpawnOptions(workDir, stdio = ['pipe', 'pipe', 'pipe'], extraEnv) {
|
|
95
|
+
const opts = {
|
|
96
|
+
cwd: workDir,
|
|
97
|
+
detached: process.platform !== 'win32',
|
|
98
|
+
stdio,
|
|
99
|
+
env: buildBaseEnv(extraEnv),
|
|
100
|
+
windowsHide: process.platform === 'win32',
|
|
101
|
+
};
|
|
102
|
+
return opts;
|
|
103
|
+
}
|
|
104
|
+
export function spawnCliProcess(command, args, workDir, stdio = ['pipe', 'pipe', 'pipe'], extraEnv) {
|
|
105
|
+
const child = spawn(command, args, buildBaseSpawnOptions(workDir, stdio, extraEnv));
|
|
106
|
+
trackChild(child);
|
|
107
|
+
return child;
|
|
108
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared connection management infrastructure for self-managing platform clients.
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates three cross-cutting concerns duplicated across wework, clawbot,
|
|
5
|
+
* qq, and workbuddy clients:
|
|
6
|
+
*
|
|
7
|
+
* - **ReconnectManager**: jittered backoff scheduling with fatal→slow-probe mode
|
|
8
|
+
* - **HeartbeatMonitor**: interval-based heartbeat / watchdog timer
|
|
9
|
+
* - **StateManager**: state variable with change-notification callback
|
|
10
|
+
*/
|
|
11
|
+
export interface ReconnectManagerConfig {
|
|
12
|
+
/** Platform name used in log messages. */
|
|
13
|
+
name: string;
|
|
14
|
+
/**
|
|
15
|
+
* Delay strategy:
|
|
16
|
+
* - `{ mode: 'exponential', baseMs, maxMs, cap }` — exponential backoff
|
|
17
|
+
* `min(baseMs * 1.5^floor(attempt / cap), maxMs)`.
|
|
18
|
+
* - `{ mode: 'stepped', delays }` — stepped array; last entry repeats.
|
|
19
|
+
*/
|
|
20
|
+
backoff: {
|
|
21
|
+
mode: 'exponential';
|
|
22
|
+
baseMs: number;
|
|
23
|
+
maxMs: number;
|
|
24
|
+
cap?: number;
|
|
25
|
+
} | {
|
|
26
|
+
mode: 'stepped';
|
|
27
|
+
delays: number[];
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* After this many attempts the counter resets and retries continue
|
|
31
|
+
* (avoids permanent disconnection). 0 = unlimited (no reset).
|
|
32
|
+
*/
|
|
33
|
+
maxAttempts?: number;
|
|
34
|
+
/** Callback invoked after the computed delay. */
|
|
35
|
+
onReconnect: () => void | Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Manages reconnection scheduling with jittered backoff and fatal→slow-probe.
|
|
39
|
+
*
|
|
40
|
+
* Replaces the duplicated `scheduleReconnect()` pattern across 4 client files:
|
|
41
|
+
* timer cleanup, attempt counting, backoff computation, jitter, logging.
|
|
42
|
+
*/
|
|
43
|
+
export declare class ReconnectManager {
|
|
44
|
+
private timer;
|
|
45
|
+
private attempt;
|
|
46
|
+
private _fatal;
|
|
47
|
+
private _stopped;
|
|
48
|
+
private readonly log;
|
|
49
|
+
private readonly cfg;
|
|
50
|
+
constructor(cfg: ReconnectManagerConfig);
|
|
51
|
+
get attemptCount(): number;
|
|
52
|
+
get fatal(): boolean;
|
|
53
|
+
get stopped(): boolean;
|
|
54
|
+
/** Mark a fatal (auth) error — next scheduleReconnect uses slow-probe delay. */
|
|
55
|
+
setFatal(value: boolean): void;
|
|
56
|
+
/** Reset attempt counter and fatal flag after a successful connection. */
|
|
57
|
+
reset(): void;
|
|
58
|
+
/**
|
|
59
|
+
* Schedule a reconnect attempt. Clears any pending timer, computes the
|
|
60
|
+
* backoff delay (with jitter), and invokes `onReconnect` after the delay.
|
|
61
|
+
*/
|
|
62
|
+
schedule(): void;
|
|
63
|
+
/** Cancel any pending reconnect timer. */
|
|
64
|
+
stop(): void;
|
|
65
|
+
/** Allow scheduling again (undo `stop`). */
|
|
66
|
+
resume(): void;
|
|
67
|
+
private clearTimer;
|
|
68
|
+
private computeBaseDelay;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Manages a setInterval-based heartbeat or watchdog timer.
|
|
72
|
+
*
|
|
73
|
+
* Replaces duplicated `heartbeatTimer` / `watchdogTimer` patterns:
|
|
74
|
+
* timer cleanup on re-start, clearInterval on stop.
|
|
75
|
+
*/
|
|
76
|
+
export declare class HeartbeatMonitor {
|
|
77
|
+
private timer;
|
|
78
|
+
private _lastResponseTime;
|
|
79
|
+
get lastResponseTime(): number;
|
|
80
|
+
/** Record that a response was received (for watchdog stale detection). */
|
|
81
|
+
recordResponse(): void;
|
|
82
|
+
/**
|
|
83
|
+
* Start a periodic callback. Clears any existing timer first.
|
|
84
|
+
* @param intervalMs Interval in milliseconds.
|
|
85
|
+
* @param onTick Callback invoked on each tick.
|
|
86
|
+
*/
|
|
87
|
+
start(intervalMs: number, onTick: () => void | Promise<void>): void;
|
|
88
|
+
/** Clear the heartbeat timer. */
|
|
89
|
+
stop(): void;
|
|
90
|
+
/**
|
|
91
|
+
* Watchdog convenience: returns true if more than `staleMs` have elapsed
|
|
92
|
+
* since the last `recordResponse()` call.
|
|
93
|
+
*/
|
|
94
|
+
isStale(staleMs: number): boolean;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Manages a connection state variable with change-notification callback.
|
|
98
|
+
*
|
|
99
|
+
* Replaces duplicated `connectionState` / `channelState` + `stateChangeHandler`
|
|
100
|
+
* patterns across wework, clawbot, and workbuddy clients.
|
|
101
|
+
*/
|
|
102
|
+
export declare class StateManager<T extends string> {
|
|
103
|
+
private _state;
|
|
104
|
+
private onChange?;
|
|
105
|
+
constructor(initialState: T);
|
|
106
|
+
get current(): T;
|
|
107
|
+
/** Register a callback invoked whenever the state changes. */
|
|
108
|
+
setOnChange(handler: ((state: T) => void) | undefined): void;
|
|
109
|
+
/** Transition to a new state and notify the callback if it changed. */
|
|
110
|
+
set(newState: T): void;
|
|
111
|
+
}
|