@wu529778790/open-im 1.9.3-beta.1 → 1.9.3-beta.3

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.
@@ -69,7 +69,13 @@ async function getOrCreateSession(sessionId, workDir, model, permissionMode) {
69
69
  process.chdir(workDir);
70
70
  }
71
71
  if (sessionId) {
72
- // 尝试恢复已有会话
72
+ // 优先复用内存中已有的 SDKSession,避免每次都启动新进程
73
+ const existing = activeSessions.get(sessionId);
74
+ if (existing) {
75
+ log.info(`Reusing existing in-memory session: ${sessionId}`);
76
+ return { session: existing, sessionId };
77
+ }
78
+ // 内存中没有,尝试通过 resume 恢复(会启动新 CLI 进程)
73
79
  try {
74
80
  log.info(`Attempting to resume session: ${sessionId}`);
75
81
  session = unstable_v2_resumeSession(sessionId, sessionOptions);
@@ -173,8 +179,10 @@ export class ClaudeSDKAdapter {
173
179
  const newSessionId = msg.session_id;
174
180
  if (newSessionId && newSessionId !== actualSessionId) {
175
181
  // 更新 sessionId 映射
176
- if (actualSessionId && actualSessionId.startsWith('pending-')) {
177
- activeSessions.delete(actualSessionId);
182
+ // 清理 pending 临时 ID(actualSessionId 尚未赋值时用 pendingTempId)
183
+ const idToClean = actualSessionId ?? pendingTempId;
184
+ if (idToClean?.startsWith('pending-')) {
185
+ activeSessions.delete(idToClean);
178
186
  }
179
187
  activeSessions.set(newSessionId, session);
180
188
  actualSessionId = newSessionId;
@@ -17,9 +17,14 @@ export interface CentrifugeClientConfig {
17
17
  * Called before sending a WeChat KF reply to update the channel's channelId
18
18
  * to the current WeChat user's externalUserId. The WorkBuddy server uses the
19
19
  * registered channelId as the WeChat send_msg `touser`, so this must match the
20
- * customer we are replying to.
20
+ * customer we are replying to. Also locks the heartbeat to prevent race conditions.
21
21
  */
22
22
  registerChannelFn?: (externalUserId: string) => Promise<void>;
23
+ /**
24
+ * Called after the COPILOT_RESPONSE is sent (success or failure) to release
25
+ * the reply lock, allowing the heartbeat to resume.
26
+ */
27
+ releaseChannelLockFn?: () => void;
23
28
  }
24
29
  /** Client callbacks */
25
30
  export interface CentrifugeCallbacks {
@@ -149,13 +149,23 @@ export class WorkBuddyCentrifugeClient {
149
149
  // The WorkBuddy server uses the registered channelId as the WeChat KF send_msg
150
150
  // `touser`. Re-register the channel with the current WeChat user's externalUserId
151
151
  // so that the server sends the reply to the correct customer.
152
- if (this.config.registerChannelFn && sessionId.includes('::')) {
153
- const externalUserId = sessionId.split('::')[0];
154
- try {
155
- await this.config.registerChannelFn(externalUserId);
156
- }
157
- catch (err) {
158
- log.warn(`${this.logPrefix} registerChannelFn failed (reply may go to wrong user):`, err);
152
+ const externalUserId = sessionId.includes('::') ? sessionId.split('::')[0] : null;
153
+ if (this.config.registerChannelFn && externalUserId) {
154
+ // Retry registerChannelFn up to 3 times on network failure
155
+ for (let attempt = 1; attempt <= 3; attempt++) {
156
+ try {
157
+ await this.config.registerChannelFn(externalUserId);
158
+ break;
159
+ }
160
+ catch (err) {
161
+ if (attempt < 3) {
162
+ log.warn(`${this.logPrefix} registerChannelFn attempt ${attempt} failed, retrying in 2s:`, err);
163
+ await new Promise((r) => setTimeout(r, 2000));
164
+ }
165
+ else {
166
+ log.warn(`${this.logPrefix} registerChannelFn failed after 3 attempts (reply may go to wrong user):`, err);
167
+ }
168
+ }
159
169
  }
160
170
  }
161
171
  const httpPayload = {
@@ -172,27 +182,44 @@ export class WorkBuddyCentrifugeClient {
172
182
  };
173
183
  const url = `${this.config.httpBaseUrl}/v2/backgroundagent/wecom/local-proxy/receive`;
174
184
  log.debug(`${this.logPrefix} HTTP COPILOT_RESPONSE → ${url} chatId=${sessionId} msgLen=${message.length}`);
175
- try {
176
- const res = await fetch(url, {
177
- method: 'POST',
178
- headers: {
179
- 'Content-Type': 'application/json',
180
- Authorization: `Bearer ${this.config.httpAccessToken}`,
181
- },
182
- body: JSON.stringify(httpPayload),
183
- signal: AbortSignal.timeout(30_000),
184
- });
185
- const body = await res.text().catch(() => '');
186
- if (!res.ok) {
187
- log.error(`${this.logPrefix} HTTP COPILOT_RESPONSE failed: ${res.status} ${body.substring(0, 300)}`);
185
+ // Retry COPILOT_RESPONSE up to 3 times on network failure
186
+ let sent = false;
187
+ for (let attempt = 1; attempt <= 3; attempt++) {
188
+ try {
189
+ const res = await fetch(url, {
190
+ method: 'POST',
191
+ headers: {
192
+ 'Content-Type': 'application/json',
193
+ Authorization: `Bearer ${this.config.httpAccessToken}`,
194
+ },
195
+ body: JSON.stringify(httpPayload),
196
+ signal: AbortSignal.timeout(30_000),
197
+ });
198
+ const body = await res.text().catch(() => '');
199
+ if (!res.ok) {
200
+ log.error(`${this.logPrefix} HTTP COPILOT_RESPONSE failed: ${res.status} ${body.substring(0, 300)}`);
201
+ }
202
+ else {
203
+ log.info(`${this.logPrefix} HTTP COPILOT_RESPONSE ok: ${res.status} ${body.substring(0, 200)}`);
204
+ }
205
+ sent = true;
206
+ break;
188
207
  }
189
- else {
190
- log.info(`${this.logPrefix} HTTP COPILOT_RESPONSE ok: ${res.status} ${body.substring(0, 200)}`);
208
+ catch (err) {
209
+ if (attempt < 3) {
210
+ log.warn(`${this.logPrefix} HTTP COPILOT_RESPONSE attempt ${attempt} failed, retrying in 2s:`, err);
211
+ await new Promise((r) => setTimeout(r, 2000));
212
+ }
213
+ else {
214
+ log.error(`${this.logPrefix} HTTP COPILOT_RESPONSE error after 3 attempts:`, err);
215
+ }
191
216
  }
192
217
  }
193
- catch (err) {
194
- log.error(`${this.logPrefix} HTTP COPILOT_RESPONSE error:`, err);
218
+ if (!sent) {
219
+ log.error(`${this.logPrefix} Failed to send COPILOT_RESPONSE after retries`);
195
220
  }
221
+ // Release the heartbeat lock so the periodic registration can resume
222
+ this.config.releaseChannelLockFn?.();
196
223
  return;
197
224
  }
198
225
  this.sendEnvelope('session.promptResponse', payload, _guid, _userId);
@@ -90,11 +90,16 @@ async function connect() {
90
90
  centrifugeClient.stop();
91
91
  centrifugeClient = null;
92
92
  }
93
+ // Mutex flag: prevents the heartbeat from overriding channelId while a reply is in flight.
94
+ let replyLock = false;
93
95
  // Re-registers the WeChat KF channel with the given externalUserId as channelId.
94
96
  // The WorkBuddy server uses channelId as the WeChat send_msg `touser`, so this
95
97
  // must be called with the customer's external_userid before sending each reply.
98
+ // Sets replyLock=true to block the heartbeat from overriding while we send.
96
99
  const registerChannelFn = async (externalUserId) => {
100
+ replyLock = true;
97
101
  const clawSessionId = oauth.buildSessionId(clawPath);
102
+ log.debug(`registerChannelFn: registering channelId=${externalUserId} (heartbeat paused)`);
98
103
  await oauth.registerChannel({
99
104
  type: 'wechatkf',
100
105
  sessionId: clawSessionId,
@@ -102,6 +107,10 @@ async function connect() {
102
107
  userId: pc.userId ?? '',
103
108
  });
104
109
  };
110
+ const releaseChannelLockFn = () => {
111
+ replyLock = false;
112
+ log.debug('registerChannelFn: reply sent, heartbeat resumed');
113
+ };
105
114
  centrifugeClient = new WorkBuddyCentrifugeClient({
106
115
  url: tokens.url,
107
116
  connectionToken: tokens.connectionToken,
@@ -113,6 +122,7 @@ async function connect() {
113
122
  httpAccessToken: pc.accessToken ?? '',
114
123
  workspaceSessionId,
115
124
  registerChannelFn,
125
+ releaseChannelLockFn,
116
126
  }, {
117
127
  onConnected: () => {
118
128
  log.info('WorkBuddy Centrifuge connected');
@@ -133,6 +143,10 @@ async function connect() {
133
143
  const doRegister = () => {
134
144
  if (stopped || channelState !== 'connected')
135
145
  return;
146
+ if (replyLock) {
147
+ log.debug('Heartbeat skipped (reply in progress)');
148
+ return;
149
+ }
136
150
  oauth.registerChannel({
137
151
  type: 'wechatkf',
138
152
  sessionId: clawSessionId,
@@ -152,6 +166,10 @@ async function connect() {
152
166
  const doRegister = () => {
153
167
  if (stopped || channelState !== 'connected')
154
168
  return;
169
+ if (replyLock) {
170
+ log.debug('Heartbeat skipped (reply in progress, fallback path)');
171
+ return;
172
+ }
155
173
  oauth.registerChannel({
156
174
  type: 'wechatkf',
157
175
  sessionId: workspaceSessionId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.9.3-beta.1",
3
+ "version": "1.9.3-beta.3",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, CodeBuddy)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",