@yeaft/webchat-agent 0.1.76 → 0.1.78

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/cli.js CHANGED
@@ -223,6 +223,7 @@ function upgradeWindows(latestVersion) {
223
223
 
224
224
  writeFileSync(batPath, batLines.join('\r\n'));
225
225
  const child = spawn('cmd.exe', ['/c', batPath], {
226
+ detached: true,
226
227
  stdio: 'ignore',
227
228
  windowsHide: true,
228
229
  });
@@ -76,7 +76,7 @@ try {
76
76
  log('Temp dir: ' + tmpDir);
77
77
 
78
78
  const packOutput = execFileSync('npm', ['pack', PKG, '--pack-destination', tmpDir], {
79
- shell: true, encoding: 'utf8', cwd: tmpDir, timeout: 120000
79
+ shell: process.platform === 'win32', encoding: 'utf8', cwd: tmpDir, timeout: 120000
80
80
  }).trim();
81
81
  const tgzName = packOutput.split('\n').pop().trim();
82
82
  const tgzPath = path.join(tmpDir, tgzName);
@@ -100,7 +100,7 @@ try {
100
100
  log('Installing dependencies...');
101
101
  try {
102
102
  execFileSync('npm', ['install', '--omit=dev'], {
103
- shell: true, cwd: TARGET, encoding: 'utf8', timeout: 120000
103
+ shell: process.platform === 'win32', cwd: TARGET, encoding: 'utf8', timeout: 120000
104
104
  });
105
105
  log('Dependencies installed');
106
106
  } catch (depErr) {
@@ -46,7 +46,7 @@ export async function handleUpgradeAgent() {
46
46
  const pkgName = ctx.pkgName || '@yeaft/webchat-agent';
47
47
  // Check latest version (async to avoid blocking heartbeat)
48
48
  const latestVersion = await new Promise((resolve, reject) => {
49
- execFile('npm', ['view', pkgName, 'version'], { stdio: 'pipe', shell: true }, (err, stdout) => {
49
+ execFile('npm', ['view', pkgName, 'version'], { stdio: 'pipe', shell: process.platform === 'win32' }, (err, stdout) => {
50
50
  if (err) reject(err); else resolve(stdout.toString().trim());
51
51
  });
52
52
  });
@@ -74,7 +74,7 @@ export async function handleUpgradeAgent() {
74
74
 
75
75
  // 判断全局安装 vs 局部安装
76
76
  const isGlobalInstall = await new Promise((resolve) => {
77
- execFile('npm', ['prefix', '-g'], { shell: true }, (err, stdout) => {
77
+ execFile('npm', ['prefix', '-g'], { shell: process.platform === 'win32' }, (err, stdout) => {
78
78
  if (err) { resolve(false); return; }
79
79
  const globalPrefix = stdout.toString().trim().replace(/\\/g, '/');
80
80
  resolve(installDir === globalPrefix || installDir === globalPrefix + '/lib');
@@ -94,7 +94,7 @@ export async function handleUpgradeAgent() {
94
94
  const isPm2 = !!process.env.pm_id;
95
95
  if (isPm2) {
96
96
  try {
97
- execFileSync('pm2', ['delete', PM2_APP_NAME], { shell: true, stdio: 'pipe' });
97
+ execFileSync('pm2', ['delete', PM2_APP_NAME], { shell: process.platform === 'win32', stdio: 'pipe' });
98
98
  console.log(`[Agent] PM2 app deleted to prevent auto-restart during upgrade`);
99
99
  } catch {
100
100
  console.log(`[Agent] PM2 delete skipped (app may not be registered)`);
@@ -201,8 +201,9 @@ function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestV
201
201
 
202
202
  writeFileSync(batPath, batLines.join('\r\n'));
203
203
  const child = spawn('cmd.exe', ['/c', batPath], {
204
+ detached: true,
204
205
  stdio: 'ignore',
205
- windowsHide: true
206
+ windowsHide: true,
206
207
  });
207
208
  child.unref();
208
209
  console.log(`[Agent] Spawned upgrade script (PID wait for ${pid}, pm2=${isPm2}, dir=${installDir}): ${batPath}`);
package/crew/control.js CHANGED
@@ -66,6 +66,11 @@ export async function handleCrewControl(msg) {
66
66
  async function clearSingleRole(session, roleName) {
67
67
  const roleState = session.roleStates.get(roleName);
68
68
 
69
+ // P0-1: 清除该角色在 humanMessageQueue 中的待处理消息,防止幽灵 ROUTE 执行
70
+ if (session.humanMessageQueue.length > 0) {
71
+ session.humanMessageQueue = session.humanMessageQueue.filter(m => m.target !== roleName);
72
+ }
73
+
69
74
  if (roleState) {
70
75
  // 保存工作摘要到 task file(与 context_exceeded clear 一致)
71
76
  if (roleState.accumulatedText) {
@@ -73,9 +78,21 @@ async function clearSingleRole(session, roleName) {
73
78
  console.warn(`[Crew] Failed to save work summary for ${roleName}:`, e.message));
74
79
  }
75
80
 
81
+ // P1-3: abort 并等待 query iterator 退出,避免 abort+dispatch 竞态
76
82
  if (roleState.abortController) {
77
83
  roleState.abortController.abort();
78
84
  }
85
+ // 等待 query 完成(iterator 会因 AbortError 退出)
86
+ if (roleState.query) {
87
+ try {
88
+ // Drain the iterator — it will throw AbortError and exit
89
+ // eslint-disable-next-line no-empty
90
+ for await (const _ of roleState.query) {}
91
+ } catch {
92
+ // Expected: AbortError or other cleanup errors
93
+ }
94
+ }
95
+
79
96
  roleState.query = null;
80
97
  roleState.inputStream = null;
81
98
  roleState.turnActive = false;
@@ -86,6 +103,10 @@ async function clearSingleRole(session, roleName) {
86
103
  roleState.lastDispatchFrom = null;
87
104
  roleState.lastDispatchTaskId = null;
88
105
  roleState.lastDispatchTaskTitle = null;
106
+ // P0-3: 重置 UI 相关状态,防止显示过时信息
107
+ roleState.currentTask = null;
108
+ roleState.currentTool = null;
109
+ roleState.lastTurnText = '';
89
110
  }
90
111
 
91
112
  await clearRoleSessionId(session.sharedDir, roleName);
@@ -168,12 +189,12 @@ async function resumeSession(session) {
168
189
  const pending = session.pendingRoutes.slice();
169
190
  session.pendingRoutes = [];
170
191
  console.log(`[Crew] Replaying ${pending.length} pending route(s)`);
171
- const results = await Promise.allSettled(pending.map(({ fromRole, route }) =>
172
- executeRoute(session, fromRole, route)
173
- ));
174
- for (const r of results) {
175
- if (r.status === 'rejected') {
176
- console.warn(`[Crew] Pending route replay failed:`, r.reason);
192
+ // P1-5: 串行执行 pending routes,防止多个 route 指向同一角色时并发 dispatch
193
+ for (const { fromRole, route } of pending) {
194
+ try {
195
+ await executeRoute(session, fromRole, route);
196
+ } catch (err) {
197
+ console.warn(`[Crew] Pending route replay failed:`, err);
177
198
  }
178
199
  }
179
200
  return;
@@ -338,6 +359,9 @@ async function stopAll(session) {
338
359
  * 清空 session
339
360
  */
340
361
  async function clearSession(session) {
362
+ // P1-2: 先重置 _processingHumanQueue,防止后续消息处理被阻塞
363
+ session._processingHumanQueue = false;
364
+
341
365
  for (const [roleName, roleState] of session.roleStates) {
342
366
  if (roleState.abortController) {
343
367
  roleState.abortController.abort();
@@ -350,6 +374,7 @@ async function clearSession(session) {
350
374
  await clearRoleSessionId(session.sharedDir, roleName);
351
375
  }
352
376
 
377
+ // P1-1: humanMessageQueue 在这里清空,确保不会有幽灵消息在 clear 后被处理
353
378
  session.messageHistory = [];
354
379
  session.uiMessages = [];
355
380
  session.humanMessageQueue = [];
@@ -86,10 +86,31 @@ export async function handleCrewHumanInput(msg) {
86
86
  const { createRoleQuery } = await import('./role-query.js');
87
87
  roleState = await createRoleQuery(session, target);
88
88
  }
89
- roleState.inputStream.enqueue({
90
- type: 'user',
91
- message: { role: 'user', content: message }
92
- });
89
+ // P1-4: 守卫 stream.enqueue
90
+ try {
91
+ if (roleState.inputStream && !roleState.inputStream.isDone) {
92
+ roleState.inputStream.enqueue({
93
+ type: 'user',
94
+ message: { role: 'user', content: message }
95
+ });
96
+ } else {
97
+ console.warn(`[Crew] Skill dispatch: stream closed for ${target}, recreating`);
98
+ const { createRoleQuery } = await import('./role-query.js');
99
+ roleState = await createRoleQuery(session, target);
100
+ roleState.inputStream.enqueue({
101
+ type: 'user',
102
+ message: { role: 'user', content: message }
103
+ });
104
+ }
105
+ } catch (enqueueErr) {
106
+ console.error(`[Crew] Skill dispatch enqueue failed for ${target}:`, enqueueErr.message);
107
+ const { createRoleQuery } = await import('./role-query.js');
108
+ roleState = await createRoleQuery(session, target);
109
+ roleState.inputStream.enqueue({
110
+ type: 'user',
111
+ message: { role: 'user', content: message }
112
+ });
113
+ }
93
114
  sendStatusUpdate(session);
94
115
  console.log(`[Crew] Skill command dispatched to ${target}: ${message}`);
95
116
  return;
@@ -84,10 +84,48 @@ export function classifyRoleError(error) {
84
84
  // Role Query Management
85
85
  // =====================================================================
86
86
 
87
+ // P1-6: Per-role mutex to prevent concurrent createRoleQuery calls creating orphan processes
88
+ const _roleQueryLocks = new Map();
89
+
87
90
  /**
88
91
  * 为角色创建持久 query 实例
89
92
  */
90
93
  export async function createRoleQuery(session, roleName) {
94
+ const lockKey = `${session.id}:${roleName}`;
95
+
96
+ // P1-6: 如果该角色已有 pending 的 createRoleQuery,等待它完成
97
+ if (_roleQueryLocks.has(lockKey)) {
98
+ console.log(`[Crew] Waiting for existing createRoleQuery lock on ${roleName}`);
99
+ try {
100
+ await _roleQueryLocks.get(lockKey);
101
+ } catch {
102
+ // Previous attempt failed, proceed with new creation
103
+ }
104
+ // 锁释放后,检查是否已经有可用的 query
105
+ const existing = session.roleStates.get(roleName);
106
+ if (existing?.query && existing?.inputStream && !existing.inputStream.isDone) {
107
+ console.log(`[Crew] Reusing existing query for ${roleName} after lock release`);
108
+ return existing;
109
+ }
110
+ }
111
+
112
+ const promise = _createRoleQueryInner(session, roleName);
113
+ _roleQueryLocks.set(lockKey, promise);
114
+ try {
115
+ const result = await promise;
116
+ return result;
117
+ } finally {
118
+ // 只删除自己的 lock(如果后续调用覆盖了,不误删)
119
+ if (_roleQueryLocks.get(lockKey) === promise) {
120
+ _roleQueryLocks.delete(lockKey);
121
+ }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * createRoleQuery 内部实现
127
+ */
128
+ async function _createRoleQueryInner(session, roleName) {
91
129
  const role = session.roles.get(roleName);
92
130
  if (!role) throw new Error(`Role not found: ${roleName}`);
93
131
 
package/crew/routing.js CHANGED
@@ -227,17 +227,48 @@ export async function dispatchToRole(session, roleName, content, fromSource, tas
227
227
  roleState = await createRoleQuery(session, roleName);
228
228
  }
229
229
 
230
- // 发送
230
+ // P1-4: 守卫 stream.enqueue — stream 可能已被 abort 关闭
231
231
  roleState.lastDispatchContent = content;
232
232
  roleState.lastDispatchFrom = fromSource;
233
233
  roleState.lastDispatchTaskId = taskId || null;
234
234
  roleState.lastDispatchTaskTitle = taskTitle || null;
235
235
  roleState.turnActive = true;
236
236
  roleState.accumulatedText = '';
237
- roleState.inputStream.enqueue({
238
- type: 'user',
239
- message: { role: 'user', content }
240
- });
237
+ try {
238
+ if (roleState.inputStream && !roleState.inputStream.isDone) {
239
+ roleState.inputStream.enqueue({
240
+ type: 'user',
241
+ message: { role: 'user', content }
242
+ });
243
+ } else {
244
+ console.warn(`[Crew] Cannot enqueue to ${roleName}: stream closed or missing, recreating`);
245
+ roleState = await createRoleQuery(session, roleName);
246
+ roleState.lastDispatchContent = content;
247
+ roleState.lastDispatchFrom = fromSource;
248
+ roleState.lastDispatchTaskId = taskId || null;
249
+ roleState.lastDispatchTaskTitle = taskTitle || null;
250
+ roleState.turnActive = true;
251
+ roleState.accumulatedText = '';
252
+ roleState.inputStream.enqueue({
253
+ type: 'user',
254
+ message: { role: 'user', content }
255
+ });
256
+ }
257
+ } catch (enqueueErr) {
258
+ console.error(`[Crew] Failed to enqueue to ${roleName}:`, enqueueErr.message);
259
+ // Recreate query and retry once
260
+ roleState = await createRoleQuery(session, roleName);
261
+ roleState.lastDispatchContent = content;
262
+ roleState.lastDispatchFrom = fromSource;
263
+ roleState.lastDispatchTaskId = taskId || null;
264
+ roleState.lastDispatchTaskTitle = taskTitle || null;
265
+ roleState.turnActive = true;
266
+ roleState.accumulatedText = '';
267
+ roleState.inputStream.enqueue({
268
+ type: 'user',
269
+ message: { role: 'user', content }
270
+ });
271
+ }
241
272
 
242
273
  sendStatusUpdate(session);
243
274
  console.log(`[Crew] Dispatched to ${roleName} from ${fromSource}${taskId ? ` (task: ${taskId})` : ''}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",