@wu529778790/open-im 1.10.9-beta.2 → 1.10.9-beta.4

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/README.md CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  Multi-platform IM bridge for AI CLI tools. Connect Telegram, Feishu, WeCom, DingTalk, QQ, and WeChat (WorkBuddy) to Claude Code, Codex, and CodeBuddy — use your AI coding assistant from any phone or chat window.
6
6
 
7
+ ## Architecture
8
+
9
+ ![Open-IM Architecture](./diagram/architecture/open-im-architecture.svg)
10
+
7
11
  ## Features
8
12
 
9
13
  - **Six IM platforms** — Telegram, Feishu, WeCom, DingTalk, QQ, WorkBuddy
package/README.zh-CN.md CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  多平台 IM 桥接:把 Telegram、飞书、企业微信、钉钉、QQ、微信(WorkBuddy)接到 Claude Code、Codex、CodeBuddy,在手机或聊天里使用 AI 编程助手。
6
6
 
7
+ ## 架构
8
+
9
+ ![Open-IM 架构图](./diagram/architecture/open-im-architecture.svg)
10
+
7
11
  ## 功能特性
8
12
 
9
13
  - **六个 IM 平台** — Telegram、飞书、企业微信、钉钉、QQ、WorkBuddy
@@ -49,6 +49,8 @@ loadUserPluginSettings();
49
49
  // 存储所有活跃的 SDKSession 对象,key 为 sessionId
50
50
  // 使用 Map 而不是 Set,因为我们需要通过 sessionId 获取 session
51
51
  const activeSessions = new Map();
52
+ // 记录每个 session 创建/恢复时的 workDir,防止跨 workDir 复用已固定 cwd 的子进程
53
+ const sessionWorkDirs = new Map();
52
54
  // 存储正在进行的流式迭代器,用于中断
53
55
  const activeStreams = new Set();
54
56
  // 空闲会话清理:跟踪最后使用时间,定期清除超时会话
@@ -92,34 +94,53 @@ const cleanupInterval = setInterval(() => {
92
94
  activeSessions.delete(id);
93
95
  }
94
96
  sessionLastUsed.delete(id);
97
+ sessionWorkDirs.delete(id);
95
98
  log.info(`Cleaned up idle session (unused ${Math.round((now - lastUsed) / 60000)}min): ${id}`);
96
99
  }
97
100
  }
98
101
  }, CLEANUP_INTERVAL_MS);
99
102
  cleanupInterval.unref(); // 不阻止进程退出
100
103
  /**
101
- * Serializes process.chdir() calls across concurrent users.
104
+ * 串行化进程级 process.chdir() —— 同一时刻仅一个 chdir 生效。
102
105
  *
103
- * process.chdir() is a process-wide global side effect — only one chdir can
104
- * be active at a time. The SDK's createSession/resumeSession do not accept a
105
- * `cwd` parameter, so we must chdir before calling them. This mutex ensures
106
- * concurrent requests don't race on the working directory.
106
+ * SDK V2 createSession/resumeSession 不接受 cwd 参数;且 send()/stream()
107
+ * 会以「调用时的 process.cwd()」派生 Claude Code 子进程。必须用互斥锁串行化
108
+ * 「整个 turn cwd 切换」,否则并发多用户会让工具跑错目录。
107
109
  *
108
- * **TODO:** Remove this mutex entirely once @anthropic-ai/claude-agent-sdk
109
- * supports a `cwd` option in createSession/resumeSession. Track upstream:
110
+ * **TODO:** SDK 支持 cwd 选项后移除此锁。upstream:
110
111
  * https://github.com/anthropics/claude-agent-sdk/issues
111
112
  */
112
113
  let chdirMutex = Promise.resolve();
113
114
  function withChdirMutex(fn) {
114
115
  const previous = chdirMutex;
115
- let resolve;
116
- chdirMutex = new Promise((r) => { resolve = r; });
117
- return previous.then(() => {
116
+ let release;
117
+ chdirMutex = new Promise((r) => { release = r; });
118
+ return previous.then(async () => {
118
119
  try {
119
- return fn();
120
+ return await fn();
120
121
  }
121
122
  finally {
122
- resolve();
123
+ release();
124
+ }
125
+ });
126
+ }
127
+ /**
128
+ * 在持有全局 chdir 互斥锁期间,把进程 cwd 切到 workDir 执行 fn,结束后恢复。
129
+ * 用于包裹 session.send()+stream(),确保子进程在正确 workDir 派生。
130
+ */
131
+ function runWithWorkDir(workDir, fn) {
132
+ return withChdirMutex(async () => {
133
+ const originalCwd = process.cwd();
134
+ if (workDir && workDir !== originalCwd) {
135
+ process.chdir(workDir);
136
+ }
137
+ try {
138
+ return await fn();
139
+ }
140
+ finally {
141
+ if (workDir && workDir !== originalCwd) {
142
+ process.chdir(originalCwd);
143
+ }
123
144
  }
124
145
  });
125
146
  }
@@ -201,18 +222,21 @@ async function getOrCreateSession(sessionId, workDir, model, permissionMode, onS
201
222
  process.chdir(workDir);
202
223
  }
203
224
  if (sessionId) {
204
- // 优先复用内存中已有的 SDKSession,避免每次都启动新进程
225
+ // 优先复用内存中已有的 SDKSession,避免每次都启动新进程。
226
+ // 仅当 workDir 与创建时一致才复用:否则子进程 cwd 已固定在旧目录,
227
+ // 需走 resume 重新派生(在当前 workDir 启动新子进程)。
205
228
  const existing = activeSessions.get(sessionId);
206
- if (existing) {
229
+ if (existing && sessionWorkDirs.get(sessionId) === workDir) {
207
230
  log.info(`Reusing existing in-memory session: ${sessionId}`);
208
231
  sessionLastUsed.set(sessionId, Date.now());
209
232
  return { session: existing, sessionId };
210
233
  }
211
- // 内存中没有,尝试通过 resume 恢复(会启动新 CLI 进程)
234
+ // 内存中没有(或 workDir 变了),尝试通过 resume 恢复(会启动新 CLI 进程)
212
235
  try {
213
236
  log.info(`Attempting to resume session: ${sessionId}`);
214
237
  session = unstable_v2_resumeSession(sessionId, sessionOptions);
215
238
  activeSessions.set(sessionId, session);
239
+ sessionWorkDirs.set(sessionId, workDir);
216
240
  sessionLastUsed.set(sessionId, Date.now());
217
241
  log.info(`Successfully resumed session: ${sessionId}`);
218
242
  return { session, sessionId };
@@ -228,6 +252,7 @@ async function getOrCreateSession(sessionId, workDir, model, permissionMode, onS
228
252
  // 暂时返回 undefined,稍后在 init 消息中获取
229
253
  const tempId = `pending-${++sessionSeq}`;
230
254
  activeSessions.set(tempId, session);
255
+ sessionWorkDirs.set(tempId, workDir);
231
256
  sessionLastUsed.set(tempId, Date.now());
232
257
  log.info(`Created new session (tempId: ${tempId})`);
233
258
  return { session, sessionId: tempId, wasReused: false };
@@ -267,6 +292,7 @@ export class ClaudeSDKAdapter {
267
292
  }
268
293
  activeSessions.clear();
269
294
  sessionLastUsed.clear();
295
+ sessionWorkDirs.clear();
270
296
  }
271
297
  /**
272
298
  * Remove a specific session from the in-memory cache and close it.
@@ -281,6 +307,7 @@ export class ClaudeSDKAdapter {
281
307
  catch { /* ignore */ }
282
308
  activeSessions.delete(sessionId);
283
309
  sessionLastUsed.delete(sessionId);
310
+ sessionWorkDirs.delete(sessionId);
284
311
  log.info(`Explicitly removed session: ${sessionId}`);
285
312
  }
286
313
  }
@@ -320,12 +347,16 @@ export class ClaudeSDKAdapter {
320
347
  }
321
348
  runningSessions.add(returnedId);
322
349
  trackedRunningId = returnedId;
323
- // 发送用户消息
324
- await session.send(prompt);
325
- // 获取响应流
326
- const stream = session.stream();
327
- currentStream = stream;
328
- activeStreams.add(stream);
350
+ // 在持有 chdir 锁期间完成 send() + stream() 获取:SDK V2 会以当前
351
+ // process.cwd() 派生 Claude Code 子进程,必须保证此刻 cwd 为 workDir。
352
+ // 锁串行化后,并发多用户的子进程不会在错误的目录派生。
353
+ const stream = await runWithWorkDir(workDir, async () => {
354
+ await session.send(prompt);
355
+ const s = session.stream();
356
+ currentStream = s;
357
+ activeStreams.add(s);
358
+ return s;
359
+ });
329
360
  let accumulated = '';
330
361
  let accumulatedThinking = '';
331
362
  const toolStats = {};
@@ -348,13 +379,19 @@ export class ClaudeSDKAdapter {
348
379
  // 更新 sessionId 映射
349
380
  // 清理 pending 临时 ID(actualSessionId 尚未赋值时用 pendingTempId)
350
381
  const idToClean = actualSessionId ?? pendingTempId;
382
+ const inheritedWorkDir = idToClean ? sessionWorkDirs.get(idToClean) : undefined;
351
383
  if (idToClean?.startsWith('pending-')) {
352
384
  activeSessions.delete(idToClean);
353
385
  }
354
386
  activeSessions.set(newSessionId, session);
387
+ if (inheritedWorkDir !== undefined) {
388
+ sessionWorkDirs.set(newSessionId, inheritedWorkDir);
389
+ }
355
390
  sessionLastUsed.set(newSessionId, Date.now());
356
- if (idToClean)
391
+ if (idToClean) {
357
392
  sessionLastUsed.delete(idToClean);
393
+ sessionWorkDirs.delete(idToClean);
394
+ }
358
395
  // 更新 runningSessions:移除旧 ID,添加新 ID
359
396
  if (idToClean)
360
397
  runningSessions.delete(idToClean);
@@ -3,6 +3,7 @@ import { ClaudeSDKAdapter, configureClaudeSdkSessionIdle } from './claude-sdk-ad
3
3
  import { CodexAdapter } from './codex-adapter.js';
4
4
  import { CodeBuddyAdapter } from './codebuddy-adapter.js';
5
5
  import { createLogger } from '../logger.js';
6
+ import { destroyAllLiveChildren } from '../shared/process-kill.js';
6
7
  const log = createLogger('Registry');
7
8
  const adapters = new Map();
8
9
  export function initAdapters(config) {
@@ -30,5 +31,7 @@ export function getAdapter(aiCommand) {
30
31
  }
31
32
  export function cleanupAdapters() {
32
33
  ClaudeSDKAdapter.destroy();
34
+ // 强制终止仍在运行的 CLI 子进程(Codex/CodeBuddy),避免僵尸 / 孤儿
35
+ destroyAllLiveChildren();
33
36
  adapters.clear();
34
37
  }
@@ -3,6 +3,7 @@ import { accessSync, constants } from 'node:fs';
3
3
  import { isAbsolute, join } from 'node:path';
4
4
  import { createLogger } from '../logger.js';
5
5
  import { processEnvForNonClaudeCliChild } from '../config/file-io.js';
6
+ import { killProcessTree, trackChild } from '../shared/process-kill.js';
6
7
  const log = createLogger('CodeBuddyCli');
7
8
  export function buildCodeBuddyArgs(prompt, sessionId, options) {
8
9
  const args = ['--print', '--output-format', 'stream-json'];
@@ -200,10 +201,14 @@ export function runCodeBuddy(cliPath, prompt, sessionId, workDir, callbacks, opt
200
201
  log.info(`Spawning CodeBuddy CLI: path=${normalizedCliPath}, cwd=${workDir}, session=${sessionId ?? 'new'}, args=${args.join(' ')}`);
201
202
  const child = spawn(spawnCmd, spawnArgs, {
202
203
  cwd: workDir,
204
+ // Unix 上放入独立进程组,使 abort/关停能用 process.kill(-pid) 杀掉整棵子树(含 MCP/shell 孙进程)
205
+ detached: process.platform !== 'win32',
203
206
  stdio: ['ignore', 'pipe', 'pipe'],
204
207
  env,
205
208
  windowsHide: process.platform === 'win32',
206
209
  });
210
+ trackChild(child);
211
+ let cliTimeoutHandle = null;
207
212
  let accumulated = '';
208
213
  let accumulatedThinking = '';
209
214
  let completed = false;
@@ -311,6 +316,10 @@ export function runCodeBuddy(cliPath, prompt, sessionId, workDir, callbacks, opt
311
316
  }
312
317
  });
313
318
  child.on('close', (code) => {
319
+ if (cliTimeoutHandle) {
320
+ clearTimeout(cliTimeoutHandle);
321
+ cliTimeoutHandle = null;
322
+ }
314
323
  if (completed)
315
324
  return;
316
325
  if (stdoutState.buffer.trim()) {
@@ -343,16 +352,36 @@ export function runCodeBuddy(cliPath, prompt, sessionId, workDir, callbacks, opt
343
352
  });
344
353
  });
345
354
  child.on('error', (err) => {
355
+ if (cliTimeoutHandle) {
356
+ clearTimeout(cliTimeoutHandle);
357
+ cliTimeoutHandle = null;
358
+ }
346
359
  if (completed)
347
360
  return;
348
361
  completed = true;
349
362
  callbacks.onError(`Failed to start CodeBuddy CLI: ${err.message}`);
350
363
  });
364
+ // 墙钟超时:防止 CLI 挂死永久占用用户队列槽
365
+ const cliTimeoutMs = Number(process.env.OPEN_IM_CLI_TIMEOUT_MS) || 30 * 60 * 1000;
366
+ cliTimeoutHandle = setTimeout(() => {
367
+ if (completed)
368
+ return;
369
+ log.warn(`CodeBuddy CLI 超过 ${cliTimeoutMs}ms,强制终止 (pid=${child.pid})`);
370
+ completed = true;
371
+ killProcessTree(child);
372
+ callbacks.onError(`CodeBuddy CLI 运行超时(${Math.round(cliTimeoutMs / 1000)}s),已终止。请重试。`);
373
+ }, cliTimeoutMs);
374
+ cliTimeoutHandle.unref();
351
375
  return {
352
376
  abort: () => {
377
+ if (completed)
378
+ return;
353
379
  completed = true;
354
- if (!child.killed)
355
- child.kill('SIGTERM');
380
+ if (cliTimeoutHandle) {
381
+ clearTimeout(cliTimeoutHandle);
382
+ cliTimeoutHandle = null;
383
+ }
384
+ killProcessTree(child);
356
385
  },
357
386
  };
358
387
  }
@@ -7,6 +7,7 @@ import { dirname, join } from 'node:path';
7
7
  import { createInterface } from 'node:readline';
8
8
  import { createLogger } from '../logger.js';
9
9
  import { processEnvForNonClaudeCliChild } from '../config/file-io.js';
10
+ import { killProcessTree, trackChild } from '../shared/process-kill.js';
10
11
  const log = createLogger('CodexCli');
11
12
  const windowsCodexLaunchCache = new Map();
12
13
  const SUPPORTED_IMAGE_EXTENSIONS = new Set([
@@ -201,10 +202,13 @@ export function runCodex(cliPath, prompt, sessionId, workDir, callbacks, options
201
202
  : args;
202
203
  const child = spawn(spawnCmd, spawnArgs, {
203
204
  cwd: workDir,
205
+ // Unix 上放入独立进程组,使 abort/关停能用 process.kill(-pid) 杀掉整棵子树(含 MCP/shell 孙进程)
206
+ detached: process.platform !== 'win32',
204
207
  stdio: ['pipe', 'pipe', 'pipe'],
205
208
  env,
206
209
  windowsHide: process.platform === 'win32',
207
210
  });
211
+ trackChild(child);
208
212
  child.stdin?.write(prompt);
209
213
  child.stdin?.end();
210
214
  let accumulated = '';
@@ -327,7 +331,12 @@ export function runCodex(cliPath, prompt, sessionId, workDir, callbacks, options
327
331
  let exitCode = null;
328
332
  let rlClosed = false;
329
333
  let childClosed = false;
334
+ let cliTimeoutHandle = null;
330
335
  const finalize = () => {
336
+ if (cliTimeoutHandle) {
337
+ clearTimeout(cliTimeoutHandle);
338
+ cliTimeoutHandle = null;
339
+ }
331
340
  if (!rlClosed || !childClosed)
332
341
  return;
333
342
  if (completed)
@@ -387,12 +396,29 @@ export function runCodex(cliPath, prompt, sessionId, workDir, callbacks, options
387
396
  childClosed = true;
388
397
  finalize();
389
398
  });
399
+ // 墙钟超时:防止 CLI 挂死(网络卡住、工具死循环等)永久占用用户队列槽
400
+ const cliTimeoutMs = Number(process.env.OPEN_IM_CLI_TIMEOUT_MS) || 30 * 60 * 1000;
401
+ cliTimeoutHandle = setTimeout(() => {
402
+ if (completed)
403
+ return;
404
+ log.warn(`Codex CLI 超过 ${cliTimeoutMs}ms,强制终止 (pid=${child.pid})`);
405
+ completed = true;
406
+ rl.close();
407
+ killProcessTree(child);
408
+ callbacks.onError(`Codex CLI 运行超时(${Math.round(cliTimeoutMs / 1000)}s),已终止。请重试。`);
409
+ }, cliTimeoutMs);
410
+ cliTimeoutHandle.unref();
390
411
  return {
391
412
  abort: () => {
413
+ if (completed)
414
+ return;
392
415
  completed = true;
416
+ if (cliTimeoutHandle) {
417
+ clearTimeout(cliTimeoutHandle);
418
+ cliTimeoutHandle = null;
419
+ }
393
420
  rl.close();
394
- if (!child.killed)
395
- child.kill('SIGTERM');
421
+ killProcessTree(child);
396
422
  },
397
423
  };
398
424
  }
@@ -19,8 +19,11 @@ export declare function getClaudeSdkRuntimeIssue(): string | null;
19
19
  export declare function parseCommaSeparated(value: string): string[];
20
20
  /**
21
21
  * 将最新的 Claude 认证环境变量按优先级合并到 process.env。
22
- * 优先级:shell 环境变量 > ~/.open-im/config.json tools.claude.env >
23
- * 本机 Claude 配置(~/.claude/settings.json,与 Claude Code 共用)。
24
- * 多数用户只维护本机 settings;每次创建 Claude SDK 会话前调用,本机文件变更后下次会话即生效。
22
+ * 优先级:shell 环境变量 > 本机 Claude 配置(~/.claude/settings.json,与 Claude Code 共用)>
23
+ * ~/.open-im/config.json tools.claude.env。
24
+ *
25
+ * 设计意图:用户只需维护 ~/.claude/settings.json(与 Claude Code CLI 共用),
26
+ * open-im 自动跟随本地 Claude 配置,无需单独配置。config.json 的 tools.claude.env
27
+ * 仅作为兜底,供没有本地 Claude 安装的场景使用。
25
28
  */
26
29
  export declare function refreshClaudeEnvToProcess(): void;
@@ -350,9 +350,12 @@ export function parseCommaSeparated(value) {
350
350
  }
351
351
  /**
352
352
  * 将最新的 Claude 认证环境变量按优先级合并到 process.env。
353
- * 优先级:shell 环境变量 > ~/.open-im/config.json tools.claude.env >
354
- * 本机 Claude 配置(~/.claude/settings.json,与 Claude Code 共用)。
355
- * 多数用户只维护本机 settings;每次创建 Claude SDK 会话前调用,本机文件变更后下次会话即生效。
353
+ * 优先级:shell 环境变量 > 本机 Claude 配置(~/.claude/settings.json,与 Claude Code 共用)>
354
+ * ~/.open-im/config.json tools.claude.env。
355
+ *
356
+ * 设计意图:用户只需维护 ~/.claude/settings.json(与 Claude Code CLI 共用),
357
+ * open-im 自动跟随本地 Claude 配置,无需单独配置。config.json 的 tools.claude.env
358
+ * 仅作为兜底,供没有本地 Claude 安装的场景使用。
356
359
  */
357
360
  export function refreshClaudeEnvToProcess() {
358
361
  const file = loadFileConfig();
@@ -363,14 +366,16 @@ export function refreshClaudeEnvToProcess() {
363
366
  process.env[key] = originalShellEnv[key];
364
367
  continue;
365
368
  }
366
- if (key in claudeToolEnv) {
367
- process.env[key] = claudeToolEnv[key];
368
- continue;
369
- }
369
+ // 优先读取 ~/.claude/settings.json(与 Claude Code CLI 共用同一配置)
370
370
  if (key in claudeSettingsEnv) {
371
371
  process.env[key] = claudeSettingsEnv[key];
372
372
  continue;
373
373
  }
374
+ // 兜底:config.json tools.claude.env(仅在没有本地 Claude 安装时需要)
375
+ if (key in claudeToolEnv) {
376
+ process.env[key] = claudeToolEnv[key];
377
+ continue;
378
+ }
374
379
  delete process.env[key];
375
380
  }
376
381
  }
@@ -1,5 +1,6 @@
1
1
  import { DWClient, TOPIC_ROBOT } from 'dingtalk-stream';
2
2
  import { createLogger } from '../logger.js';
3
+ import { jitteredDelay } from '../shared/reconnect.js';
3
4
  import { registerSessionWebhook as registerWebhook, clearWebhooks, sendText as webhookSendText, sendMarkdown as webhookSendMarkdown, downloadRobotMessageFile as webhookDownloadFile, sendProactiveText as webhookSendProactive, } from './webhook.js';
4
5
  import { prepareStreamingCard as cardPrepare, updateStreamingCard as cardUpdate, finishStreamingCard as cardFinish, createAndDeliverCard as cardCreateAndDeliver, updateCardInstance as cardUpdateInstance, sendRobotInteractiveCard as cardSendInteractive, updateRobotInteractiveCard as cardUpdateInteractive, clearUnionIdCache, } from './streaming-card.js';
5
6
  const log = createLogger('DingTalk');
@@ -111,7 +112,7 @@ export async function initDingTalk(cfg, eventHandler) {
111
112
  lastErr = err;
112
113
  if (is429(err) && attempt < maxTries) {
113
114
  log.warn(`DingTalk gateway 429 (attempt ${attempt}/${maxTries}), retrying in ${retryDelayMs / 1000}s...`);
114
- await new Promise((r) => setTimeout(r, retryDelayMs));
115
+ await new Promise((r) => setTimeout(r, jitteredDelay(retryDelayMs)));
115
116
  continue;
116
117
  }
117
118
  throw new Error(formatDingTalkInitError(err));
@@ -163,7 +163,7 @@ export function setupDingTalkHandlers(config, sessionManager) {
163
163
  sessionManager,
164
164
  sender: { sendTextReply, sendDirectorySelection },
165
165
  });
166
- const { accessControl, requestQueue, runningTasks, commandHandler } = ctx;
166
+ const { accessControl, requestQueue, runningTasks } = ctx;
167
167
  // DingTalk-specific sender callbacks for the factory
168
168
  const dingtalkSender = {
169
169
  sendThinkingMessage: async (chatId, replyToMessageId, toolId) => {
package/dist/index.js CHANGED
@@ -27,11 +27,13 @@ import { sendTextReply as sendWorkBuddyTextReply } from "./workbuddy/message-sen
27
27
  import { initAdapters, cleanupAdapters } from "./adapters/registry.js";
28
28
  import { SessionManager } from "./session/session-manager.js";
29
29
  import { loadActiveChats, getActiveChatId, flushActiveChats, } from "./shared/active-chats.js";
30
+ import { destroyAllLiveChildren } from "./shared/process-kill.js";
30
31
  import { initLogger, createLogger, closeLogger, emitStructuredEvent, shutdownLoggerTelemetry, } from "./logger.js";
31
32
  import { APP_HOME, SHUTDOWN_PORT } from "./constants.js";
32
33
  import { createRequire } from "node:module";
33
34
  import { escapePathForMarkdown } from "./shared/utils.js";
34
35
  import { applyOpenImGitCoauthorToProcessEnv } from "./shared/git-coauthor.js";
36
+ import { emitInterruptedTerminals } from "./shared/task-cleanup.js";
35
37
  const require = createRequire(import.meta.url);
36
38
  const { version: APP_VERSION } = require("../package.json");
37
39
  const log = createLogger("Main");
@@ -262,6 +264,15 @@ export async function main() {
262
264
  const uptimeSec = Math.floor((Date.now() - startedAt) / 1000);
263
265
  const m = Math.floor(uptimeSec / 60);
264
266
  const shutdownMsg = buildShutdownMessage(m);
267
+ // 在任何异步等待之前,先为仍在运行的任务补发终态遥测事件。
268
+ // 这样即使后续 await(通知发送、platform.stop)期间进程被第二次信号杀死,
269
+ // ai.task.start 也有对应的 interrupted 终态,避免遥测里的 miss。
270
+ for (const platform of successfulPlatforms) {
271
+ const handle = activeHandles.get(platform);
272
+ if (handle?.runningTasks && handle.runningTasks.size > 0) {
273
+ emitInterruptedTerminals(handle.runningTasks);
274
+ }
275
+ }
265
276
  // Send notification only to successfully initialized platforms
266
277
  for (const platform of successfulPlatforms) {
267
278
  await sendLifecycleNotification(platform, shutdownMsg).catch((err) => {
@@ -299,6 +310,16 @@ export async function main() {
299
310
  };
300
311
  process.on("SIGINT", () => shutdown().catch(() => process.exit(1)));
301
312
  process.on("SIGTERM", () => shutdown().catch(() => process.exit(1)));
313
+ // 兜底:进程退出(含异常路径,如未捕获异常 / SIGKILL)时强制清理 CLI 子进程,
314
+ // 避免僵尸 / 孤儿。正常 shutdown 已在 cleanupAdapters() 里清理过。
315
+ process.on("exit", () => {
316
+ try {
317
+ destroyAllLiveChildren();
318
+ }
319
+ catch {
320
+ /* 退出期间 best effort */
321
+ }
322
+ });
302
323
  // Global error handlers to prevent unhandled crashes
303
324
  process.on("unhandledRejection", (reason) => {
304
325
  log.error("Unhandled Promise rejection (this indicates a bug — the affected request may hang without a response):", reason);
@@ -312,6 +333,18 @@ export async function main() {
312
333
  return;
313
334
  }
314
335
  log.error("Uncaught exception (process will exit):", err);
336
+ // 进程即将因未捕获异常退出:补发在途任务的终态,避免 miss
337
+ for (const platform of successfulPlatforms) {
338
+ const handle = activeHandles.get(platform);
339
+ if (handle?.runningTasks && handle.runningTasks.size > 0) {
340
+ try {
341
+ emitInterruptedTerminals(handle.runningTasks);
342
+ }
343
+ catch {
344
+ /* best effort:不能因遥测失败再次抛出 */
345
+ }
346
+ }
347
+ }
315
348
  void shutdownLoggerTelemetry()
316
349
  .then(() => closeLogger())
317
350
  .finally(() => process.exit(1));
package/dist/qq/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import WebSocket from "ws";
2
2
  import { createLogger } from "../logger.js";
3
+ import { jitteredDelay, SLOW_PROBE_MS } from "../shared/reconnect.js";
3
4
  const log = createLogger("QQ");
4
5
  const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
5
6
  const API_BASE = "https://api.sgroup.qq.com";
@@ -271,7 +272,12 @@ async function connectWebSocket(config, handler) {
271
272
  sessionId = null;
272
273
  seq = null;
273
274
  }
274
- const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
275
+ 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
+ if (isFatalClose) {
279
+ log.warn(`QQ 致命关闭码 ${code}(鉴权失败),转慢探测(每 ${Math.round(SLOW_PROBE_MS / 1000)}s 一次)`);
280
+ }
275
281
  reconnectAttempt += 1;
276
282
  reconnectTimer = setTimeout(() => {
277
283
  if (currentConfig && currentHandler) {
@@ -80,17 +80,18 @@ export class RequestQueue {
80
80
  finally {
81
81
  this.runningControllers.delete(key);
82
82
  const q = this.queues.get(key);
83
- if (!q)
84
- return;
85
- const next = q.tasks.shift();
86
- if (next) {
87
- setImmediate(() => this.run(key, next.prompt, next.execute).catch((err) => {
88
- log.error(`Unhandled error in next task execution for ${key}:`, err);
89
- }));
90
- }
83
+ if (!q) { /* queue already cleared */ }
91
84
  else {
92
- q.running = false;
93
- this.queues.delete(key);
85
+ const next = q.tasks.shift();
86
+ if (next) {
87
+ setImmediate(() => this.run(key, next.prompt, next.execute).catch((err) => {
88
+ log.error(`Unhandled error in next task execution for ${key}:`, err);
89
+ }));
90
+ }
91
+ else {
92
+ q.running = false;
93
+ this.queues.delete(key);
94
+ }
94
95
  }
95
96
  }
96
97
  }
@@ -42,5 +42,19 @@ export interface TaskRunState {
42
42
  startedAt: number;
43
43
  /** AI 工具标识,用于动态显示工具名称。 */
44
44
  toolId: string;
45
+ /**
46
+ * 进程退出(shutdown / 崩溃)时,用于为仍在运行的任务补发一条终态遥测事件,
47
+ * 避免 `ai.task.start` 没有对应的 complete/error(遥测里的 `miss`)。
48
+ * 已哈希(与 ai.task.* emit 处一致),不含原始 userId/msgId。
49
+ */
50
+ taskKey: string;
51
+ platform: string;
52
+ /** 已哈希的 userId,与 ai.task.start/complete/error 中的 userKey 一致。 */
53
+ userKey: string;
45
54
  }
55
+ /**
56
+ * 将 AI/CLI 错误文本归类为一个稳定的 errorType 字符串,用于遥测聚合。
57
+ * 分类基于线上真实错误签名(见 logs/r2-events),新增分支时同步更新测试。
58
+ */
59
+ export declare function classifyErrorType(error: string): string;
46
60
  export declare function runAITask(deps: TaskDeps, ctx: TaskContext, prompt: string, toolAdapter: ToolAdapter, platformAdapter: TaskAdapter): Promise<void>;