@yeaft/webchat-agent 1.0.64 → 1.0.65

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.
@@ -16,51 +16,84 @@ export const BUFFERABLE_TYPES = new Set([
16
16
  'crew_role_compact', 'crew_context_usage'
17
17
  ]);
18
18
 
19
- // Send message to server (with encryption if available)
20
- // 断连时对关键消息类型进行缓冲,重连后自动 flush
21
- export async function sendToServer(msg) {
19
+ function bufferMessage(msg, reason) {
20
+ if (!BUFFERABLE_TYPES.has(msg.type)) {
21
+ console.warn(`[WS] Cannot send message, WebSocket not open: ${msg.type}`);
22
+ return 'dropped';
23
+ }
24
+ if (ctx.messageBuffer.length < ctx.messageBufferMaxSize) {
25
+ ctx.messageBuffer.push(msg);
26
+ console.log(`[WS] ${reason}, buffered: ${msg.type} (queue: ${ctx.messageBuffer.length})`);
27
+ return 'buffered';
28
+ }
29
+ // Buffer full: drop oldest non-status messages to make room
30
+ const dropIdx = ctx.messageBuffer.findIndex(m => m.type !== 'crew_status' && m.type !== 'turn_completed');
31
+ if (dropIdx >= 0) {
32
+ ctx.messageBuffer.splice(dropIdx, 1);
33
+ ctx.messageBuffer.push(msg);
34
+ console.warn(`[WS] Buffer full, dropped oldest to make room for: ${msg.type}`);
35
+ return 'buffered';
36
+ }
37
+ console.warn(`[WS] Buffer full (${ctx.messageBufferMaxSize}), dropping: ${msg.type}`);
38
+ return 'dropped';
39
+ }
40
+
41
+ async function sendNow(msg) {
22
42
  if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN) {
23
- // 缓冲关键消息
24
- if (BUFFERABLE_TYPES.has(msg.type)) {
25
- if (ctx.messageBuffer.length < ctx.messageBufferMaxSize) {
26
- ctx.messageBuffer.push(msg);
27
- console.log(`[WS] Buffered message: ${msg.type} (queue: ${ctx.messageBuffer.length})`);
28
- } else {
29
- // Buffer full: drop oldest non-status messages to make room
30
- const dropIdx = ctx.messageBuffer.findIndex(m => m.type !== 'crew_status' && m.type !== 'turn_completed');
31
- if (dropIdx >= 0) {
32
- ctx.messageBuffer.splice(dropIdx, 1);
33
- ctx.messageBuffer.push(msg);
34
- console.warn(`[WS] Buffer full, dropped oldest to make room for: ${msg.type}`);
35
- } else {
36
- console.warn(`[WS] Buffer full (${ctx.messageBufferMaxSize}), dropping: ${msg.type}`);
43
+ return bufferMessage(msg, 'Disconnected');
44
+ }
45
+
46
+ // feat-ws-plaintext-negotiation: encrypt only when the server has
47
+ // NOT advertised plaintext acceptance. Defaults to encrypted for
48
+ // back-compat with old servers; flipped to plaintext when the
49
+ // `registered` frame includes `acceptPlaintext: true`.
50
+ if (ctx.serverEncryptionRequired && ctx.sessionKey) {
51
+ const encrypted = await encrypt(msg, ctx.sessionKey);
52
+ ctx.ws.send(JSON.stringify(encrypted));
53
+ } else {
54
+ ctx.ws.send(JSON.stringify(msg));
55
+ }
56
+ return 'sent';
57
+ }
58
+
59
+ function scheduleOutboundDrain() {
60
+ if (ctx.outboundSendQueueActive) return;
61
+ ctx.outboundSendQueueActive = true;
62
+ setImmediate(async () => {
63
+ try {
64
+ while (ctx.outboundSendQueue.length > 0) {
65
+ const item = ctx.outboundSendQueue.shift();
66
+ const msg = item?.msg ?? item;
67
+ try {
68
+ const outcome = await sendNow(msg);
69
+ item?.resolve?.(outcome);
70
+ } catch (e) {
71
+ console.error(`[WS] Error sending message ${msg?.type}:`, e.message);
72
+ const outcome = msg ? bufferMessage(msg, 'Send failed') : 'dropped';
73
+ item?.resolve?.(outcome);
37
74
  }
75
+ // Yield between frames so ping/pong, inbound control messages and UI
76
+ // events cannot be starved by a reconnect flush or a burst of tool output.
77
+ await new Promise(resolve => setImmediate(resolve));
38
78
  }
39
- } else {
40
- console.warn(`[WS] Cannot send message, WebSocket not open: ${msg.type}`);
79
+ } finally {
80
+ ctx.outboundSendQueueActive = false;
81
+ if (ctx.outboundSendQueue.length > 0) scheduleOutboundDrain();
41
82
  }
42
- return;
43
- }
83
+ });
84
+ }
44
85
 
45
- try {
46
- // feat-ws-plaintext-negotiation: encrypt only when the server has
47
- // NOT advertised plaintext acceptance. Defaults to encrypted for
48
- // back-compat with old servers; flipped to plaintext when the
49
- // `registered` frame includes `acceptPlaintext: true`.
50
- if (ctx.serverEncryptionRequired && ctx.sessionKey) {
51
- const encrypted = await encrypt(msg, ctx.sessionKey);
52
- ctx.ws.send(JSON.stringify(encrypted));
53
- } else {
54
- ctx.ws.send(JSON.stringify(msg));
55
- }
56
- } catch (e) {
57
- console.error(`[WS] Error sending message ${msg.type}:`, e.message);
58
- // 发送失败也缓冲
59
- if (BUFFERABLE_TYPES.has(msg.type) && ctx.messageBuffer.length < ctx.messageBufferMaxSize) {
60
- ctx.messageBuffer.push(msg);
61
- console.log(`[WS] Send failed, buffered: ${msg.type}`);
62
- }
86
+ // Send message to server (with encryption if available)
87
+ // 断连时对关键消息类型进行缓冲,重连后自动 flush
88
+ export async function sendToServer(msg) {
89
+ if (!ctx.ws || ctx.ws.readyState !== WebSocket.OPEN) {
90
+ return bufferMessage(msg, 'Disconnected');
63
91
  }
92
+ const promise = new Promise((resolve, reject) => {
93
+ ctx.outboundSendQueue.push({ msg, resolve, reject });
94
+ });
95
+ scheduleOutboundDrain();
96
+ return promise;
64
97
  }
65
98
 
66
99
  // Flush 断连期间缓冲的消息
@@ -74,7 +107,7 @@ export async function flushMessageBuffer() {
74
107
  await sendToServer(msg);
75
108
  }
76
109
 
77
- console.log(`[WS] Flush complete`);
110
+ console.log(`[WS] Flush queued`);
78
111
  }
79
112
 
80
113
  // Parse incoming message (decrypt if encrypted)
@@ -1,4 +1,5 @@
1
1
  const DEFAULT_AGENT_HEARTBEAT_TIMEOUT_MS = 180000;
2
+ const DEFAULT_AGENT_HEARTBEAT_STALL_GRACE_MS = 5000;
2
3
 
3
4
  function parsePositiveInt(value, fallback) {
4
5
  const n = Number(value);
@@ -10,6 +11,17 @@ export const AGENT_HEARTBEAT_TIMEOUT_MS = parsePositiveInt(
10
11
  DEFAULT_AGENT_HEARTBEAT_TIMEOUT_MS,
11
12
  );
12
13
 
13
- export function shouldReconnectForHeartbeat(lastPongAt, now = Date.now(), timeoutMs = AGENT_HEARTBEAT_TIMEOUT_MS) {
14
+ export const AGENT_HEARTBEAT_STALL_GRACE_MS = parsePositiveInt(
15
+ process.env.YEAFT_AGENT_HEARTBEAT_STALL_GRACE_MS || process.env.AGENT_HEARTBEAT_STALL_GRACE_MS,
16
+ DEFAULT_AGENT_HEARTBEAT_STALL_GRACE_MS,
17
+ );
18
+
19
+ export function shouldReconnectForHeartbeat(
20
+ lastPongAt,
21
+ now = Date.now(),
22
+ timeoutMs = AGENT_HEARTBEAT_TIMEOUT_MS,
23
+ { timerDriftMs = 0, stallGraceMs = AGENT_HEARTBEAT_STALL_GRACE_MS } = {},
24
+ ) {
25
+ if (Number.isFinite(timerDriftMs) && timerDriftMs > stallGraceMs) return false;
14
26
  return Number.isFinite(lastPongAt) && lastPongAt > 0 && (now - lastPongAt) > timeoutMs;
15
27
  }
@@ -2,9 +2,13 @@ import WebSocket from 'ws';
2
2
  import ctx from '../context.js';
3
3
  import { shouldReconnectForHeartbeat } from './heartbeat-policy.js';
4
4
 
5
+ const AGENT_HEARTBEAT_INTERVAL_MS = 25000;
6
+ const HEARTBEAT_STALL_LOG_MS = 5000;
7
+
5
8
  export function startAgentHeartbeat() {
6
9
  stopAgentHeartbeat();
7
10
  ctx.lastPongAt = Date.now();
11
+ ctx.lastHeartbeatTickAt = Date.now();
8
12
 
9
13
  // 监听 pong 帧
10
14
  if (ctx.ws) {
@@ -18,8 +22,15 @@ export function startAgentHeartbeat() {
18
22
 
19
23
  // 检查上次 pong 是否超时
20
24
  const now = Date.now();
25
+ const timerDriftMs = Math.max(0, now - (ctx.lastHeartbeatTickAt || now) - AGENT_HEARTBEAT_INTERVAL_MS);
26
+ ctx.lastHeartbeatTickAt = now;
21
27
  const sincePong = now - ctx.lastPongAt;
22
- if (shouldReconnectForHeartbeat(ctx.lastPongAt, now)) {
28
+ if (timerDriftMs > HEARTBEAT_STALL_LOG_MS) {
29
+ ctx.lastHeartbeatStallAt = now;
30
+ ctx.lastHeartbeatStallMs = timerDriftMs;
31
+ console.warn(`[Heartbeat] Agent event loop delayed heartbeat by ${Math.round(timerDriftMs)}ms; deferring reconnect`);
32
+ }
33
+ if (shouldReconnectForHeartbeat(ctx.lastPongAt, now, undefined, { timerDriftMs })) {
23
34
  console.warn(`[Heartbeat] No pong for ${Math.round(sincePong / 1000)}s, reconnecting...`);
24
35
  ctx.ws.terminate();
25
36
  return;
@@ -30,7 +41,7 @@ export function startAgentHeartbeat() {
30
41
  } catch (e) {
31
42
  console.warn('[Heartbeat] Failed to send ping:', e.message);
32
43
  }
33
- }, 25000);
44
+ }, AGENT_HEARTBEAT_INTERVAL_MS);
34
45
  }
35
46
 
36
47
  export function stopAgentHeartbeat() {
@@ -329,7 +329,7 @@ export async function handleMessage(msg) {
329
329
  break;
330
330
 
331
331
  case 'restart_agent':
332
- handleRestartAgent();
332
+ await handleRestartAgent();
333
333
  break;
334
334
 
335
335
  case 'upgrade_agent':
@@ -64,9 +64,9 @@ function cleanupAndExit(exitCode) {
64
64
  }, 500);
65
65
  }
66
66
 
67
- export function handleRestartAgent() {
67
+ export async function handleRestartAgent() {
68
68
  console.log('[Agent] Restart requested, shutting down for PM2/systemd restart...');
69
- sendToServer({ type: 'restart_agent_ack' });
69
+ await sendToServer({ type: 'restart_agent_ack' });
70
70
  cleanupAndExit(1);
71
71
  }
72
72
 
@@ -162,7 +162,7 @@ export async function handleUpgradeAgent() {
162
162
  });
163
163
  if (latestVersion === ctx.agentVersion) {
164
164
  console.log(`[Agent] Already at latest version (${ctx.agentVersion}), skipping upgrade.`);
165
- sendToServer({ type: 'upgrade_agent_ack', success: true, alreadyLatest: true, version: ctx.agentVersion });
165
+ await sendToServer({ type: 'upgrade_agent_ack', success: true, alreadyLatest: true, version: ctx.agentVersion });
166
166
  return;
167
167
  }
168
168
 
@@ -175,7 +175,7 @@ export async function handleUpgradeAgent() {
175
175
  if (requiredNode && !nodeRangeSatisfied(currentNode, requiredNode)) {
176
176
  const msg = `Node ${currentNode} does not satisfy required ${requiredNode} for ${pkgName}@${latestVersion}`;
177
177
  console.warn(`[Agent] Upgrade aborted: ${msg}`);
178
- sendToServer({
178
+ await sendToServer({
179
179
  type: 'upgrade_agent_ack',
180
180
  success: false,
181
181
  reason: 'node_incompatible',
@@ -197,7 +197,7 @@ export async function handleUpgradeAgent() {
197
197
  if (!isNpmInstall) {
198
198
  // 源码运行不支持远程升级(代码在 git repo 中,需要手动 git pull)
199
199
  console.log('[Agent] Source-based install detected, remote upgrade not supported.');
200
- sendToServer({ type: 'upgrade_agent_ack', success: false, error: 'Source-based install: please use git pull to upgrade' });
200
+ await sendToServer({ type: 'upgrade_agent_ack', success: false, error: 'Source-based install: please use git pull to upgrade' });
201
201
  return;
202
202
  }
203
203
 
@@ -217,9 +217,9 @@ export async function handleUpgradeAgent() {
217
217
  const instanceId = resolveInstanceId();
218
218
 
219
219
  if (isWindows) {
220
- spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId);
220
+ await spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId);
221
221
  } else {
222
- spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId);
222
+ await spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId);
223
223
  }
224
224
 
225
225
  // On PM2: delete the app BEFORE exiting so PM2 won't auto-restart the old version.
@@ -238,11 +238,11 @@ export async function handleUpgradeAgent() {
238
238
  cleanupAndExit(0);
239
239
  } catch (e) {
240
240
  console.error('[Agent] Upgrade failed:', e.message);
241
- sendToServer({ type: 'upgrade_agent_ack', success: false, error: e.message });
241
+ await sendToServer({ type: 'upgrade_agent_ack', success: false, error: e.message });
242
242
  }
243
243
  }
244
244
 
245
- function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
245
+ async function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
246
246
  const pid = process.pid;
247
247
  const configDir = getConfigDir(instanceId);
248
248
  mkdirSync(configDir, { recursive: true });
@@ -364,7 +364,7 @@ function spawnWindowsUpgradeScript(pkgName, installDir, isGlobalInstall, latestV
364
364
  }).unref();
365
365
 
366
366
  console.log(`[Agent] Spawned upgrade via VBScript (PID wait for ${pid}, pm2=${isPm2}, dir=${installDir}): ${batPath}`);
367
- sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
367
+ await sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
368
368
  }
369
369
 
370
370
  /**
@@ -526,7 +526,7 @@ export function buildUnixUpgradeScript({
526
526
  return shLines.join('\n');
527
527
  }
528
528
 
529
- function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
529
+ async function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVersion, instanceId = DEFAULT_INSTANCE_ID) {
530
530
  const configDir = getConfigDir(instanceId);
531
531
  // Create logs/ too: the generated script's `exec > "$LOGFILE" 2>&1` aborts
532
532
  // the whole upgrade silently if that directory is missing.
@@ -551,5 +551,5 @@ function spawnUnixUpgradeScript(pkgName, installDir, isGlobalInstall, latestVers
551
551
  });
552
552
  child.unref();
553
553
  console.log(`[Agent] Spawned upgrade script: ${shPath}`);
554
- sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
554
+ await sendToServer({ type: 'upgrade_agent_ack', success: true, version: latestVersion, pendingRestart: true });
555
555
  }
package/context.js CHANGED
@@ -28,6 +28,11 @@ export default {
28
28
  pendingAuthTempId: null,
29
29
  agentHeartbeatTimer: null,
30
30
  lastPongAt: 0,
31
+ lastHeartbeatTickAt: 0,
32
+ lastHeartbeatStallAt: 0,
33
+ lastHeartbeatStallMs: 0,
34
+ outboundSendQueue: [],
35
+ outboundSendQueueActive: false,
31
36
  // 断连期间的消息缓冲队列(重连后 flush)
32
37
  messageBuffer: [],
33
38
  messageBufferMaxSize: 5000, // 防止内存无限增长
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.64",
3
+ "version": "1.0.65",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",