openclaw-agent-dashboard 1.0.33 → 1.0.34

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/index.js CHANGED
@@ -10,7 +10,15 @@
10
10
  * 3. openclaw.json 中 plugins.entries.openclaw-agent-dashboard.config.port
11
11
  * 4. 默认 38271
12
12
  *
13
- * 启动前:若首选端口被占用且 /api/version 确认为本插件 Dashboard,则在 Unix 上结束旧进程(SIGTERM → 必要时 SIGKILL;无 lsof 时 Linux 可试 fuser),尽量仍在 38271 起新实例。
13
+ * 启动前:若首选端口被占用且 /api/version 确认为本插件 Dashboard,则在 Unix 上结束旧进程
14
+ * (SIGTERM → 必要时 SIGKILL;无 lsof 时 Linux 可试 fuser),尽量仍在 38271 起新实例。
15
+ *
16
+ * 热重载恢复(评审文档 FR-1~FR-9):
17
+ * - activeStartId 所有权互斥 + 世代号防止并发双起与 stop 后仍 spawn(FR-7/FR-9)
18
+ * - stop 带超时等待子进程退出(FR-1)
19
+ * - 启动重试 + 探测 + 回收循环(FR-2/FR-3/FR-5)
20
+ * - spawn 后就绪探测(FR-9)
21
+ * - PID 文件辅助孤儿发现(FR-8)
14
22
  */
15
23
  const path = require('path');
16
24
  const os = require('os');
@@ -19,8 +27,51 @@ const net = require('net');
19
27
  const http = require('http');
20
28
  const { spawn, execFileSync } = require('child_process');
21
29
 
30
+ // ── 模块级状态 ──────────────────────────────────────────────────────────────
22
31
  let dashboardProcess = null;
23
32
 
33
+ /** 世代号:每次进入异步启动链时递增,stop 时 bump,用于作废旧的异步链 */
34
+ let generation = 0;
35
+ /** 中止标志:stop 时置 true,start 入口时重置为 false */
36
+ let aborted = false;
37
+
38
+ /**
39
+ * 互斥锁:保证全局最多一条「选口→spawn→就绪确认」的启动链在途。
40
+ * 采用所有权语义:值为本次启动的 generation(非零),0 表示空闲。
41
+ * 仅持有该 generation 的 IIFE 可在 finally 中释放(比较 activeStartId === myId),
42
+ * stop() 和其他链不得修改此值——避免旧链误清新链的锁。
43
+ *
44
+ * 残余窗口:stop() 后、旧链 finally 执行前 activeStartId !== 0,
45
+ * 新的 startDashboard() 会「启动链已在进行中, 跳过」。若宿主不会再次调用
46
+ * startDashboard,理论上存在极短窗口内「看起来没自动拉起」;但旧链会在
47
+ * 下一个 await 边界因 generation/aborted 快速退出并释放锁。
48
+ * 若需更强保证(宿主仅调一次 start),可在锁释放后自动重试(后续增强)。
49
+ */
50
+ let activeStartId = 0;
51
+
52
+ // ── 常量 / 可通过环境变量覆盖 ────────────────────────────────────────────────
53
+ const DEFAULT_PORT = 38271;
54
+ const MAX_START_RETRIES = parseInt(process.env.DASHBOARD_START_MAX_RETRIES, 10) || 5;
55
+ const TOTAL_RETRY_TIMEOUT_MS = parseInt(process.env.DASHBOARD_START_TOTAL_TIMEOUT_MS, 10) || 15000;
56
+ const BACKOFF_BASE_MS = 500;
57
+ const STOP_SIGTERM_TIMEOUT_MS = 5000;
58
+ const STOP_SIGKILL_TIMEOUT_MS = 3000;
59
+ const READY_CHECK_TIMEOUT_MS = 8000;
60
+ const READY_CHECK_INTERVAL_MS = 500;
61
+ const PID_FILENAME = 'dashboard-runtime.json';
62
+
63
+ const LOG_PREFIX = '[OpenClaw-Dashboard]';
64
+
65
+ // ── 工具函数 ────────────────────────────────────────────────────────────────
66
+
67
+ function log(...args) { console.log(LOG_PREFIX, ...args); }
68
+ function logWarn(...args) { console.warn(LOG_PREFIX, ...args); }
69
+ function logError(...args) { console.error(LOG_PREFIX, ...args); }
70
+
71
+ function sleepMs(ms) {
72
+ return new Promise((r) => setTimeout(r, ms));
73
+ }
74
+
24
75
  /** 解析系统 Python(Linux/macOS 多为 python3,Windows 多为 python) */
25
76
  function resolveSystemPython() {
26
77
  if (process.env.PYTHON_CMD) {
@@ -41,17 +92,16 @@ function getOpenClawHome() {
41
92
  return process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
42
93
  }
43
94
 
44
- /** Dashboard 数据目录,与 Python 端一致:本工程数据统一放此,不写入 ~/.openclaw */
95
+ /** Dashboard 数据目录 */
45
96
  function getDashboardDataDir() {
46
97
  return process.env.OPENCLAW_AGENT_DASHBOARD_DATA || path.join(os.homedir(), '.openclaw-agent-dashboard');
47
98
  }
48
99
 
49
100
  function getDashboardDir() {
50
- const pluginDir = __dirname;
51
- return path.join(pluginDir, 'dashboard');
101
+ return path.join(__dirname, 'dashboard');
52
102
  }
53
103
 
54
- /** 兼容旧路径:若存在 ~/.openclaw/dashboard 或 ~/.openclaw-dashboard 下的 config.json 则迁移到新目录一次 */
104
+ /** 兼容旧路径:若存在旧 config.json 则迁移到新目录 */
55
105
  function migrateLegacyConfigIfNeeded() {
56
106
  const openclawHome = getOpenClawHome();
57
107
  const newDir = getDashboardDataDir();
@@ -59,7 +109,7 @@ function migrateLegacyConfigIfNeeded() {
59
109
  if (fs.existsSync(newPath)) return;
60
110
  const legacyPaths = [
61
111
  path.join(openclawHome, 'dashboard', 'config.json'),
62
- path.join(os.homedir(), '.openclaw-dashboard', 'config.json')
112
+ path.join(os.homedir(), '.openclaw-dashboard', 'config.json'),
63
113
  ];
64
114
  for (const legacyPath of legacyPaths) {
65
115
  if (!fs.existsSync(legacyPath)) continue;
@@ -75,8 +125,7 @@ function migrateLegacyConfigIfNeeded() {
75
125
  * 加载配置,优先级:env > 用户 config.json(新路径 > 旧路径)> api.pluginConfig > 默认
76
126
  */
77
127
  function loadConfig(apiPluginConfig = {}) {
78
- const openclawHome = getOpenClawHome();
79
- let config = { port: 38271, autoStart: true }; // 使用罕见端口避免冲突
128
+ let config = { port: DEFAULT_PORT, autoStart: true };
80
129
 
81
130
  if (apiPluginConfig && typeof apiPluginConfig.port === 'number') {
82
131
  config.port = apiPluginConfig.port;
@@ -95,7 +144,7 @@ function loadConfig(apiPluginConfig = {}) {
95
144
 
96
145
  migrateLegacyConfigIfNeeded();
97
146
  const userConfigPath = path.join(getDashboardDataDir(), 'config.json');
98
- const legacyUserConfigPath = path.join(openclawHome, 'dashboard', 'config.json');
147
+ const legacyUserConfigPath = path.join(getOpenClawHome(), 'dashboard', 'config.json');
99
148
  const prevUserConfigPath = path.join(os.homedir(), '.openclaw-dashboard', 'config.json');
100
149
  const pathToRead = fs.existsSync(userConfigPath) ? userConfigPath
101
150
  : (fs.existsSync(prevUserConfigPath) ? prevUserConfigPath : legacyUserConfigPath);
@@ -115,6 +164,8 @@ function loadConfig(apiPluginConfig = {}) {
115
164
  return config;
116
165
  }
117
166
 
167
+ // ── 端口检测 ────────────────────────────────────────────────────────────────
168
+
118
169
  /** 检测端口是否可用 */
119
170
  function isPortAvailable(port) {
120
171
  return new Promise((resolve) => {
@@ -127,7 +178,7 @@ function isPortAvailable(port) {
127
178
  });
128
179
  }
129
180
 
130
- /** 检测端口是否已被占用(有进程在监听) */
181
+ /** 检测端口是否已被占用 */
131
182
  function isPortInUse(port) {
132
183
  return new Promise((resolve) => {
133
184
  const server = net.createServer();
@@ -148,7 +199,9 @@ async function findAvailablePort(basePort, maxAttempts = 10) {
148
199
  return basePort;
149
200
  }
150
201
 
151
- /** Python 端 VersionInfo.name 一致(来自 openclaw.plugin.json / package.json) */
202
+ // ── 探测与回收 ──────────────────────────────────────────────────────────────
203
+
204
+ /** 与 Python 端 VersionInfo.name 一致 */
152
205
  function getExpectedDashboardApiName() {
153
206
  try {
154
207
  const manifestPath = path.join(__dirname, 'openclaw.plugin.json');
@@ -163,7 +216,6 @@ function getExpectedDashboardApiName() {
163
216
 
164
217
  /**
165
218
  * 探测本机 port 上是否为当前插件的 Dashboard(FastAPI /api/version)
166
- * @returns {Promise<boolean>}
167
219
  */
168
220
  function probeOurDashboardListening(port) {
169
221
  const expected = getExpectedDashboardApiName();
@@ -194,7 +246,6 @@ function probeOurDashboardListening(port) {
194
246
 
195
247
  /**
196
248
  * 获取在 port 上 LISTEN 的进程 PID(Unix)。Windows 暂不支持,返回空数组。
197
- * @returns {number[]}
198
249
  */
199
250
  function getListenPidsOnPort(port) {
200
251
  if (process.platform === 'win32') {
@@ -220,13 +271,9 @@ function getListenPidsOnPort(port) {
220
271
  return [...new Set(pids)];
221
272
  }
222
273
 
223
- function sleepMs(ms) {
224
- return new Promise((r) => setTimeout(r, ms));
225
- }
226
-
227
274
  /**
228
- * Linux:无 lsof PID 时尝试 fuser 释放监听该 TCP 端口的进程(仅在已确认 /api/version 为本插件后调用)。
229
- * @param {number} port
275
+ * Linux:无 lsof PID 时尝试 fuser 释放监听该 TCP 端口的进程。
276
+ * 仅在已确认 /api/version 为本插件后调用。
230
277
  */
231
278
  function tryFuserKillTcpPort(port) {
232
279
  if (process.platform !== 'linux') return;
@@ -238,8 +285,41 @@ function tryFuserKillTcpPort(port) {
238
285
  }
239
286
 
240
287
  /**
241
- * Gateway 重启/升级后旧 Dashboard 常仍占用首选端口,导致新实例落到 38272 或「跳过启动」仍连旧进程。
242
- * 若占用者为本插件 Dashboard(/api/version 校验),则多轮结束旧进程后再启动(仅 Unix;Windows 不变)。
288
+ * 单轮回收:尝试结束占用 port 的本插件 Dashboard 进程。
289
+ *
290
+ * 前提:调用方必须已通过 probeOurDashboardListening 确认端口上为本插件 Dashboard,
291
+ * 本函数不做二次 probe(与 tryFuserKillTcpPort 约定一致)。
292
+ *
293
+ * 返回 true 表示本轮执行了 kill / fuser 操作。
294
+ */
295
+ function reclaimOneRound(port) {
296
+ const pids = getListenPidsOnPort(port);
297
+ if (pids.length > 0) {
298
+ for (const pid of pids) {
299
+ try {
300
+ process.kill(pid, 'SIGTERM');
301
+ } catch (_) {}
302
+ }
303
+ log(`回收端口 ${port}: 已向 PID ${pids.join(', ')} 发送 SIGTERM`);
304
+ return true;
305
+ }
306
+
307
+ /* 有 lsof 但拿不到 PID(极少见),或 Windows 无 PID 手段 */
308
+ if (process.platform === 'linux') {
309
+ log(`回收端口 ${port}: 无 lsof PID, 尝试 fuser`);
310
+ tryFuserKillTcpPort(port);
311
+ return true;
312
+ }
313
+
314
+ if (process.platform === 'win32') {
315
+ logWarn(`回收端口 ${port}: Windows 无 PID 手段, 跳过 reclaim (将走备用端口或告警)`);
316
+ }
317
+ return false;
318
+ }
319
+
320
+ /**
321
+ * Gateway 重启/升级后旧 Dashboard 常仍占用首选端口。
322
+ * 若占用者为本插件 Dashboard(/api/version 校验),则多轮结束旧进程后再启动。
243
323
  */
244
324
  async function reclaimStaleOurDashboardPort(port) {
245
325
  const maxPasses = 4;
@@ -256,138 +336,441 @@ async function reclaimStaleOurDashboardPort(port) {
256
336
  const pids = getListenPidsOnPort(port);
257
337
  if (pids.length > 0) {
258
338
  const sig = pass === 0 ? 'SIGTERM' : 'SIGKILL';
259
- console.log(
260
- `[OpenClaw-Dashboard] 端口 ${port} 被上一实例 Dashboard 占用 (PID: ${pids.join(', ')}),${sig} 以便在 ${port} 启动新实例`
339
+ log(
340
+ `端口 ${port} 被上一实例 Dashboard 占用 (PID: ${pids.join(', ')}), ${sig} 以便在 ${port} 启动新实例`
261
341
  );
262
342
  for (const pid of pids) {
263
343
  try {
264
344
  process.kill(pid, sig);
265
345
  } catch (e) {
266
- console.warn(`[OpenClaw-Dashboard] 无法向 PID ${pid} 发送 ${sig}:`, e.message || e);
346
+ logWarn(`无法向 PID ${pid} 发送 ${sig}:`, e.message || e);
267
347
  }
268
348
  }
269
349
  await sleepMs(pass === 0 ? 1200 : 800);
270
350
  continue;
271
351
  }
272
352
 
273
- /* 已确认是本插件 HTTP 服务,但拿不到 PID(常见:未装 lsof) */
274
353
  if (process.platform === 'linux' && pass >= maxPasses - 2) {
275
- console.log(
276
- `[OpenClaw-Dashboard] 端口 ${port} 上为本插件 Dashboard,尝试 fuser 释放(建议安装 lsof 以便精确杀进程)`
277
- );
354
+ log(`端口 ${port} 上为本插件 Dashboard, 尝试 fuser 释放(建议安装 lsof)`);
278
355
  tryFuserKillTcpPort(port);
279
356
  await sleepMs(700);
280
357
  continue;
281
358
  }
282
359
 
283
360
  if (pass === 0) {
284
- console.log(
285
- `[OpenClaw-Dashboard] 端口 ${port} 上检测到本插件 Dashboard,但无法解析占用进程(可安装 lsof),稍后重试或换端口`
286
- );
361
+ log(`端口 ${port} 上检测到本插件 Dashboard, 但无法解析占用进程(可安装 lsof), 稍后重试或换端口`);
287
362
  }
288
363
  await sleepMs(500);
289
364
  }
290
365
  }
291
366
 
292
- function startDashboard(config = {}) {
293
- // 仅在 Gateway 进程内自动启动(OPENCLAW_GATEWAY_PORT 由 Gateway 设置)
294
- // CLI 命令(status、health 等)加载插件时不会设置此变量,故不启动
295
- if (!process.env.OPENCLAW_GATEWAY_PORT?.trim()) {
296
- return;
367
+ // ── PID 文件(FR-8)────────────────────────────────────────────────────────
368
+
369
+ function getPidFilePath() {
370
+ return path.join(getDashboardDataDir(), PID_FILENAME);
371
+ }
372
+
373
+ function writePidFile(childPid, port) {
374
+ try {
375
+ const dir = getDashboardDataDir();
376
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
377
+ const data = {
378
+ gatewayPid: process.pid,
379
+ childPid,
380
+ port,
381
+ startedAt: new Date().toISOString(),
382
+ };
383
+ fs.writeFileSync(getPidFilePath(), JSON.stringify(data, null, 2), 'utf8');
384
+ } catch (_) {}
385
+ }
386
+
387
+ function removePidFile() {
388
+ try {
389
+ const p = getPidFilePath();
390
+ if (fs.existsSync(p)) fs.unlinkSync(p);
391
+ } catch (_) {}
392
+ }
393
+
394
+ /**
395
+ * 读取旧 PID 文件,若端口被占且为本插件则执行 reclaim。
396
+ * PID 文件仅作辅助线索,实际 kill 前仍须 /api/version 二次确认。
397
+ *
398
+ * 返回 true 表示回收成功(端口已释放或本来就是空的);
399
+ * 返回 false 表示端口仍被占用(可能是本插件但回收失败,也可能是其他服务)。
400
+ */
401
+ async function reclaimFromPidFile(basePort) {
402
+ try {
403
+ const p = getPidFilePath();
404
+ if (!fs.existsSync(p)) return true;
405
+ const data = JSON.parse(fs.readFileSync(p, 'utf8'));
406
+ if (!data || typeof data.port !== 'number') {
407
+ removePidFile();
408
+ return true;
409
+ }
410
+
411
+ const port = data.port;
412
+
413
+ if (!(await isPortInUse(port))) {
414
+ // 端口已空闲,旧实例已退出,清理文件
415
+ removePidFile();
416
+ return true;
417
+ }
418
+
419
+ const ours = await probeOurDashboardListening(port);
420
+ if (!ours) {
421
+ // 非本插件占用,不动,保留 PID 文件供后续参考
422
+ log(`PID 文件记录端口 ${port}, 但 /api/version 表明非本插件, 保留文件`);
423
+ return false;
424
+ }
425
+
426
+ log(`PID 文件发现旧实例占用端口 ${port} (旧 childPid: ${data.childPid}), 执行回收`);
427
+ await reclaimStaleOurDashboardPort(port);
428
+
429
+ // 仅在确认端口已释放时才删文件,否则保留供下次启动再试
430
+ if (await isPortAvailable(port)) {
431
+ removePidFile();
432
+ return true;
433
+ }
434
+
435
+ logWarn(`PID 文件旧实例回收后端口 ${port} 仍被占用, 保留 PID 文件`);
436
+ return false;
437
+ } catch (_) {
438
+ return true;
297
439
  }
440
+ }
298
441
 
299
- if (dashboardProcess) {
300
- console.log('[OpenClaw-Dashboard] 服务已在运行');
301
- return;
442
+ // ── 就绪探测(FR-9)────────────────────────────────────────────────────────
443
+
444
+ /**
445
+ * spawn 后轮询 /api/version 直至就绪或超时。
446
+ * 返回 true 表示 Dashboard 已就绪;false 表示超时或世代失配。
447
+ */
448
+ async function waitForReady(port, startGen, timeoutMs = READY_CHECK_TIMEOUT_MS) {
449
+ const start = Date.now();
450
+ while (Date.now() - start < timeoutMs) {
451
+ if (generation !== startGen || aborted) {
452
+ return false;
453
+ }
454
+ const ready = await probeOurDashboardListening(port);
455
+ if (ready) {
456
+ return true;
457
+ }
458
+ await sleepMs(READY_CHECK_INTERVAL_MS);
302
459
  }
460
+ return false;
461
+ }
303
462
 
304
- const dashboardDir = getDashboardDir();
305
- const openclawHome = getOpenClawHome();
463
+ // ── 核心:stop / start ─────────────────────────────────────────────────────
306
464
 
307
- if (!fs.existsSync(dashboardDir)) {
308
- console.warn('[OpenClaw-Dashboard] dashboard 目录不存在,请先执行 npm run deploy 安装插件');
465
+ /**
466
+ * FR-1: 停止 Dashboard 子进程,带后台超时等待。
467
+ * stop() 本身保持同步(不阻塞调用方),子进程退出等待异步进行。
468
+ */
469
+ function stopDashboard() {
470
+ // FR-7: bump generation + set aborted
471
+ // 注意:不修改 activeStartId——旧链会因 generation !== startGen 自行退出,
472
+ // 其 finally 中只有 activeStartId === myId 时才会清锁,不会误清新链。
473
+ generation++;
474
+ aborted = true;
475
+
476
+ const child = dashboardProcess;
477
+ dashboardProcess = null;
478
+
479
+ if (!child || child.killed) {
480
+ removePidFile();
309
481
  return;
310
482
  }
311
483
 
312
- const basePort = config.port ?? 38271;
313
- const isExplicitPort = basePort !== 38271; // 用户显式配置了端口(非默认 38271)
314
- const autoStart = config.autoStart !== false; // 默认 true,可配置为 false 禁用自动启动
484
+ // 发送 SIGTERM
485
+ try {
486
+ child.kill('SIGTERM');
487
+ } catch (_) {}
488
+ log('服务停止中 (SIGTERM)');
489
+
490
+ // 后台异步等待退出(不阻塞 stop 调用方)
491
+ let settled = false;
492
+ let sigtermTimer = null;
493
+ let sigkillTimer = null;
494
+
495
+ const settle = (exited) => {
496
+ if (settled) return;
497
+ settled = true;
498
+ if (sigtermTimer) clearTimeout(sigtermTimer);
499
+ if (sigkillTimer) clearTimeout(sigkillTimer);
500
+ removePidFile();
501
+ if (exited) {
502
+ log('服务已停止, 子进程已退出');
503
+ } else {
504
+ logWarn('服务停止超时, 子进程可能仍在运行');
505
+ }
506
+ };
315
507
 
316
- if (!autoStart) {
317
- return;
508
+ child.once('exit', () => {
509
+ settle(true);
510
+ });
511
+
512
+ // T1 超时后 SIGKILL
513
+ sigtermTimer = setTimeout(() => {
514
+ if (settled) return;
515
+ try {
516
+ if (!child.killed) {
517
+ child.kill('SIGKILL');
518
+ logWarn('子进程未在 SIGTERM 超时内退出, 已发送 SIGKILL');
519
+ }
520
+ } catch (_) {}
521
+
522
+ // T2: SIGKILL 后最终超时
523
+ sigkillTimer = setTimeout(() => {
524
+ settle(false);
525
+ }, STOP_SIGKILL_TIMEOUT_MS);
526
+ }, STOP_SIGTERM_TIMEOUT_MS);
527
+ }
528
+
529
+ /**
530
+ * FR-9 辅助: spawn + 就绪探测。
531
+ * 调用前必须已持有 activeStartId 锁(activeStartId === startGen)。
532
+ * 返回 true 表示成功启动并就绪。
533
+ */
534
+ async function spawnAndReadyCheck(port, startGen, env, dashboardDir, pythonCmd, args) {
535
+ if (generation !== startGen || aborted) return false;
536
+ // activeStartId 锁已由调用方保证,直接 spawn
537
+ const child = spawn(pythonCmd, args, {
538
+ env,
539
+ cwd: dashboardDir,
540
+ stdio: ['ignore', 'ignore', 'ignore'],
541
+ });
542
+
543
+ child.on('error', (err) => {
544
+ logError('启动失败:', err.message);
545
+ if (dashboardProcess === child) dashboardProcess = null;
546
+ });
547
+
548
+ child.on('exit', (code, signal) => {
549
+ if (dashboardProcess === child) dashboardProcess = null;
550
+ if (code !== 0 && code !== null) {
551
+ log(`进程退出 code=${code} signal=${signal}`);
552
+ }
553
+ });
554
+
555
+ // 立即占位 dashboardProcess(spawn 原子赋值,JS 单线程无竞态)
556
+ dashboardProcess = child;
557
+
558
+ // FR-9: 就绪探测
559
+ const ready = await waitForReady(port, startGen);
560
+ if (!ready) {
561
+ // 就绪超时或世代失配 → kill 该子进程
562
+ try { child.kill('SIGKILL'); } catch (_) {}
563
+ if (dashboardProcess === child) dashboardProcess = null;
564
+ return false;
318
565
  }
319
566
 
320
- const portPromise = (async () => {
321
- await reclaimStaleOurDashboardPort(basePort);
322
- return isExplicitPort ? basePort : await findAvailablePort(basePort);
323
- })();
567
+ // 就绪成功 PID 文件(FR-8)
568
+ writePidFile(child.pid, port);
569
+ return true;
570
+ }
324
571
 
325
- portPromise.then(async (port) => {
326
- if (dashboardProcess) return;
572
+ /**
573
+ * FR-2/FR-3/FR-5: 带退避重试的启动循环。
574
+ * 调用前必须已持有 activeStartId 锁(activeStartId === startGen)。
575
+ * 锁由外层 IIFE 的 finally 释放,本函数不操作 activeStartId。
576
+ */
577
+ async function startWithRetry(port, isExplicitPort, startGen) {
578
+ const startTime = Date.now();
579
+ let currentPort = port;
580
+
581
+ for (let attempt = 1; attempt <= MAX_START_RETRIES; attempt++) {
582
+ // 世代校验
583
+ if (generation !== startGen || aborted) {
584
+ log(`启动链已作废 (世代 ${startGen} → ${generation})`);
585
+ return;
586
+ }
327
587
 
328
- // 若端口已被占用,认为 Dashboard 已在其他进程运行,跳过启动,避免重复实例
329
- if (await isPortInUse(port)) {
330
- console.log(`[OpenClaw-Dashboard] 端口 ${port} 已被占用,Dashboard 可能已在运行,跳过启动`);
588
+ const elapsed = Date.now() - startTime;
589
+ if (elapsed >= TOTAL_RETRY_TIMEOUT_MS) {
590
+ logError(`启动失败: 重试总超时 (${TOTAL_RETRY_TIMEOUT_MS}ms), 端口 ${currentPort}`);
331
591
  return;
332
592
  }
333
593
 
334
- const env = {
335
- ...process.env,
336
- OPENCLAW_HOME: openclawHome,
337
- DASHBOARD_PORT: String(port),
338
- };
594
+ // ── 端口可用 → spawn ──
595
+ const portAvailable = await isPortAvailable(currentPort);
596
+ if (portAvailable) {
597
+ const env = {
598
+ ...process.env,
599
+ OPENCLAW_HOME: getOpenClawHome(),
600
+ DASHBOARD_PORT: String(currentPort),
601
+ };
339
602
 
340
- // 优先使用插件 venv 的 Python(安装时 venv 优先,避免 PEP 668)
341
- // venv 存在但不完整(如缺 python3-venv 导致 ensurepip 失败、无 uvicorn),回退到 python3
342
- const venvPythonUnix = path.join(dashboardDir, '.venv', 'bin', 'python');
343
- const venvPythonWin = path.join(dashboardDir, '.venv', 'Scripts', 'python.exe');
344
- let pythonCmd = process.env.PYTHON_CMD;
345
- if (!pythonCmd) {
346
- const venvPython = fs.existsSync(venvPythonUnix) ? venvPythonUnix : (fs.existsSync(venvPythonWin) ? venvPythonWin : null);
347
- if (venvPython) {
348
- try {
349
- execFileSync(venvPython, ['-c', 'import uvicorn'], { stdio: 'ignore', timeout: 3000 });
350
- pythonCmd = venvPython;
351
- } catch (_) {
352
- pythonCmd = resolveSystemPython(); // venv 不完整,回退到系统 Python
353
- }
603
+ const pythonCmd = resolvePythonCmd();
604
+ const args = ['-m', 'uvicorn', 'main:app', '--host', '0.0.0.0', '--port', String(currentPort)];
605
+
606
+ if (!isExplicitPort && currentPort !== port) {
607
+ log(`端口 ${port} 被占用, 尝试 ${currentPort} (${attempt}/${MAX_START_RETRIES})`);
354
608
  } else {
355
- pythonCmd = resolveSystemPython();
609
+ log(`尝试启动 (${attempt}/${MAX_START_RETRIES}), 端口 ${currentPort}`);
610
+ }
611
+
612
+ const ok = await spawnAndReadyCheck(currentPort, startGen, env, getDashboardDir(), pythonCmd, args);
613
+
614
+ if (ok) {
615
+ log(`插件服务已启动`);
616
+ log(`访问地址: http://localhost:${currentPort}`);
617
+ return;
356
618
  }
619
+
620
+ // spawn 或就绪失败 → 退避重试
621
+ const backoff = BACKOFF_BASE_MS * attempt;
622
+ logWarn(`端口 ${currentPort} spawn/就绪失败, ${backoff}ms 后重试 (${attempt}/${MAX_START_RETRIES})`);
623
+ await sleepMs(backoff);
624
+ continue;
357
625
  }
358
- const args = ['-m', 'uvicorn', 'main:app', '--host', '0.0.0.0', '--port', String(port)];
359
626
 
360
- if (!isExplicitPort && port !== basePort) {
361
- console.log(`[OpenClaw-Dashboard] 端口 ${basePort} 被占用,使用 ${port}`);
627
+ // ── 端口被占用 探测 + 决策 ──
628
+ const ours = await probeOurDashboardListening(currentPort);
629
+
630
+ if (ours) {
631
+ // FR-3: 本插件旧实例 → 回收
632
+ log(`[ours] 端口 ${currentPort} 被本插件旧实例占用, 执行回收 (${attempt}/${MAX_START_RETRIES})`);
633
+ reclaimOneRound(currentPort);
634
+ const backoff = BACKOFF_BASE_MS * attempt;
635
+ await sleepMs(backoff);
636
+ continue;
362
637
  }
363
- console.log(`[OpenClaw-Dashboard] 插件服务已启动`);
364
- console.log(`[OpenClaw-Dashboard] 访问地址: http://localhost:${port}`);
365
638
 
366
- dashboardProcess = spawn(pythonCmd, args, {
367
- env,
368
- cwd: dashboardDir,
369
- stdio: ['ignore', 'ignore', 'ignore'],
370
- });
639
+ // 非本插件占用
640
+ if (isExplicitPort) {
641
+ // FR-5: 显式固定端口 → 不误杀,重试 + 告警
642
+ logWarn(
643
+ `[other] 端口 ${currentPort} 被非本插件服务占用, 不可回收, 重试中 (${attempt}/${MAX_START_RETRIES})`
644
+ );
645
+ const backoff = BACKOFF_BASE_MS * attempt;
646
+ await sleepMs(backoff);
647
+ continue;
648
+ }
371
649
 
372
- dashboardProcess.on('error', (err) => {
373
- console.error('[OpenClaw-Dashboard] 启动失败:', err.message);
374
- dashboardProcess = null;
375
- });
376
- dashboardProcess.on('exit', (code, signal) => {
377
- dashboardProcess = null;
378
- if (code !== 0 && code !== null) {
379
- console.log(`[OpenClaw-Dashboard] 进程退出 code=${code} signal=${signal}`);
380
- }
381
- });
382
- });
650
+ // FR-4: 非显式端口 → 尝试备用端口
651
+ const altPort = await findAvailablePort(currentPort + 1);
652
+ if (altPort !== currentPort) {
653
+ log(`[other] 端口 ${currentPort} 被占用, 切换到备用端口 ${altPort}`);
654
+ currentPort = altPort;
655
+ continue;
656
+ }
657
+
658
+ // 无可用备用端口
659
+ logWarn(
660
+ `[unknown] 端口 ${currentPort} 被占用且无备用端口, 重试中 (${attempt}/${MAX_START_RETRIES})`
661
+ );
662
+ const backoff = BACKOFF_BASE_MS * attempt;
663
+ await sleepMs(backoff);
664
+ }
665
+
666
+ logError(
667
+ `启动失败: 重试耗尽 (${MAX_START_RETRIES} 次), 最后尝试端口 ${currentPort}`
668
+ );
383
669
  }
384
670
 
385
- function stopDashboard() {
671
+ /**
672
+ * 解析 Python 命令(venv 优先 → 系统 Python)
673
+ */
674
+ function resolvePythonCmd() {
675
+ const dashboardDir = getDashboardDir();
676
+ const venvPythonUnix = path.join(dashboardDir, '.venv', 'bin', 'python');
677
+ const venvPythonWin = path.join(dashboardDir, '.venv', 'Scripts', 'python.exe');
678
+
679
+ if (process.env.PYTHON_CMD) return process.env.PYTHON_CMD;
680
+
681
+ const venvPython = fs.existsSync(venvPythonUnix) ? venvPythonUnix
682
+ : (fs.existsSync(venvPythonWin) ? venvPythonWin : null);
683
+
684
+ if (venvPython) {
685
+ try {
686
+ execFileSync(venvPython, ['-c', 'import uvicorn'], { stdio: 'ignore', timeout: 3000 });
687
+ return venvPython;
688
+ } catch (_) {
689
+ return resolveSystemPython();
690
+ }
691
+ }
692
+ return resolveSystemPython();
693
+ }
694
+
695
+ function startDashboard(config = {}) {
696
+ // 仅在 Gateway 进程内自动启动
697
+ if (!process.env.OPENCLAW_GATEWAY_PORT?.trim()) {
698
+ return;
699
+ }
700
+
386
701
  if (dashboardProcess) {
387
- dashboardProcess.kill('SIGTERM');
388
- dashboardProcess = null;
389
- console.log('[OpenClaw-Dashboard] 服务已停止');
702
+ log('服务已在运行');
703
+ return;
704
+ }
705
+
706
+ if (activeStartId !== 0) {
707
+ log('启动链已在进行中, 跳过');
708
+ return;
709
+ }
710
+
711
+ const dashboardDir = getDashboardDir();
712
+ if (!fs.existsSync(dashboardDir)) {
713
+ logWarn('dashboard 目录不存在, 请先执行 npm run deploy 安装插件');
714
+ return;
715
+ }
716
+
717
+ const basePort = config.port ?? DEFAULT_PORT;
718
+ const isExplicitPort = basePort !== DEFAULT_PORT;
719
+ const autoStart = config.autoStart !== false;
720
+
721
+ if (!autoStart) {
722
+ return;
390
723
  }
724
+
725
+ // 仅在即将启动异步链时递增世代 + 重置中止标志
726
+ generation++;
727
+ aborted = false;
728
+ const startGen = generation;
729
+
730
+ // 占位互斥锁:activeStartId 设为本次 generation
731
+ activeStartId = startGen;
732
+
733
+ // 异步启动链
734
+ (async () => {
735
+ try {
736
+ // FR-8: 先从 PID 文件检查旧实例
737
+ await reclaimFromPidFile(basePort);
738
+
739
+ // 世代校验(PID 文件回收可能耗时)
740
+ if (generation !== startGen || aborted) {
741
+ log(`启动链 ${startGen} 在 PID 文件回收后被作废`);
742
+ return;
743
+ }
744
+
745
+ // 回收已知旧实例
746
+ await reclaimStaleOurDashboardPort(basePort);
747
+
748
+ // 世代校验
749
+ if (generation !== startGen || aborted) {
750
+ log(`启动链 ${startGen} 在 stale port 回收后被作废`);
751
+ return;
752
+ }
753
+
754
+ // 选择端口
755
+ const port = isExplicitPort ? basePort : await findAvailablePort(basePort);
756
+
757
+ // 世代校验
758
+ if (generation !== startGen || aborted) {
759
+ log(`启动链 ${startGen} 在端口选择后被作废`);
760
+ return;
761
+ }
762
+
763
+ // FR-2/3/5: 带重试的启动
764
+ await startWithRetry(port, isExplicitPort, startGen);
765
+ } catch (err) {
766
+ logError(`启动链 ${startGen} 异常:`, err.message || err);
767
+ } finally {
768
+ // 所有权释放:仅当 activeStartId 仍归我所有时才清锁
769
+ if (activeStartId === startGen) {
770
+ activeStartId = 0;
771
+ }
772
+ }
773
+ })();
391
774
  }
392
775
 
393
776
  /**
@@ -395,10 +778,13 @@ function stopDashboard() {
395
778
  * @param {object} api - OpenClaw 插件 API,pluginConfig 来自 openclaw.json 的 config 字段
396
779
  */
397
780
  function DashboardPlugin(api) {
398
- console.log('[OpenClaw-Dashboard] 插件已加载');
399
-
400
781
  const pluginConfig = (api && api.pluginConfig) || {};
401
782
  const config = loadConfig(pluginConfig);
783
+
784
+ if (process.env.OPENCLAW_GATEWAY_PORT?.trim()) {
785
+ log('插件已加载, 准备启动 Dashboard');
786
+ }
787
+
402
788
  startDashboard(config);
403
789
 
404
790
  return {
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-agent-dashboard",
3
3
  "name": "OpenClaw Agent Dashboard",
4
4
  "description": "多 Agent 可视化看板 - 状态、任务、API、工作流、协作流程",
5
- "version": "1.0.33",
5
+ "version": "1.0.34",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-agent-dashboard",
3
- "version": "1.0.33",
3
+ "version": "1.0.34",
4
4
  "description": "多 Agent 可视化看板 - OpenClaw 插件",
5
5
  "main": "index.js",
6
6
  "openclaw": {