@aion0/forge 0.10.39 → 0.10.40

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/RELEASE_NOTES.md CHANGED
@@ -1,12 +1,11 @@
1
- # Forge v0.10.39
1
+ # Forge v0.10.40
2
2
 
3
3
  Released: 2026-06-05
4
4
 
5
- ## Changes since v0.10.38
5
+ ## Changes since v0.10.39
6
6
 
7
7
  ### Other
8
- - feat(chat): auto-link bug/MR/CVE refs in AI output (#32)
9
- - fix(chat): heal orphan tool_use blocks in session history
8
+ - fix(server): portable port-pid lookup so Linux without lsof can stop (#33)
10
9
 
11
10
 
12
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.38...v0.10.39
11
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.39...v0.10.40
@@ -289,17 +289,59 @@ if (!isStop) {
289
289
  }
290
290
  }
291
291
 
292
+ // ── Find pids listening on a TCP port (portable) ──
293
+ // macOS ships lsof; minimal Linux containers (RHEL/CentOS/Alpine) often
294
+ // don't. Try lsof → ss (iproute2, ~universal on modern Linux) → fuser
295
+ // (psmisc) in order. Empty result means no listener, period.
296
+ //
297
+ // Without this, `forge server stop` on Linux silently leaked next-server
298
+ // because `lsof -ti:8403` threw ENOENT and the whole stop path was
299
+ // wrapped in try/catch — the port lookup just disappeared.
300
+ function findPortPids(port) {
301
+ // 1) lsof
302
+ try {
303
+ const out = execSync(`lsof -ti:${port}`, {
304
+ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
305
+ }).trim();
306
+ if (out) return [...new Set(out.split('\n').map(s => s.trim()).filter(Boolean))];
307
+ } catch { /* fall through */ }
308
+ // 2) ss -tlnp — line looks like:
309
+ // LISTEN 0 511 *:8403 ... users:(("next-server",pid=903438,fd=14))
310
+ try {
311
+ const out = execSync(`ss -tlnp 2>/dev/null`, {
312
+ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
313
+ });
314
+ const pids = new Set();
315
+ for (const line of out.split('\n')) {
316
+ // Match port at end of local-address field, before either whitespace
317
+ // (IPv4 `*:8403 `) or end-of-field (IPv6 `[::]:8403 `).
318
+ if (!new RegExp(`:${port}\\b`).test(line)) continue;
319
+ for (const m of line.matchAll(/pid=(\d+)/g)) pids.add(m[1]);
320
+ }
321
+ if (pids.size) return [...pids];
322
+ } catch { /* fall through */ }
323
+ // 3) fuser
324
+ try {
325
+ const out = execSync(`fuser ${port}/tcp 2>/dev/null`, {
326
+ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
327
+ });
328
+ const pids = out.trim().split(/\s+/).filter(p => /^\d+$/.test(p));
329
+ if (pids.length) return [...new Set(pids)];
330
+ } catch { /* nothing more */ }
331
+ return [];
332
+ }
333
+
292
334
  // ── Reset terminal server (kill port + tmux sessions) ──
293
335
  if (resetTerminal) {
294
336
  console.log(`[forge] Resetting terminal server (port ${terminalPort})...`);
295
- try {
296
- const pids = execSync(`lsof -ti:${terminalPort}`, { encoding: 'utf-8' }).trim();
297
- for (const pid of pids.split('\n').filter(Boolean)) {
298
- try { execSync(`kill ${pid.trim()}`); } catch {}
337
+ const pids = findPortPids(terminalPort);
338
+ if (pids.length === 0) {
339
+ console.log(`[forge] No process on port ${terminalPort}`);
340
+ } else {
341
+ for (const pid of pids) {
342
+ try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
299
343
  }
300
344
  console.log(`[forge] Killed terminal server on port ${terminalPort}`);
301
- } catch {
302
- console.log(`[forge] No process on port ${terminalPort}`);
303
345
  }
304
346
  }
305
347
 
@@ -338,14 +380,10 @@ function cleanupOrphans() {
338
380
  try {
339
381
  // Kill processes on our ports
340
382
  for (const port of [webPort, terminalPort]) {
341
- try {
342
- const pids = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
343
- for (const pid of pids.split('\n').filter(Boolean)) {
344
- const p = pid.trim();
345
- if (p === myPid || protectedPids.has(p)) continue;
346
- try { process.kill(parseInt(p), 'SIGTERM'); } catch {}
347
- }
348
- } catch {}
383
+ for (const p of findPortPids(port)) {
384
+ if (p === myPid || protectedPids.has(p)) continue;
385
+ try { process.kill(parseInt(p), 'SIGTERM'); } catch {}
386
+ }
349
387
  }
350
388
  // Kill standalone processes: our instance's + orphans without any tag
351
389
  try {
@@ -365,11 +403,25 @@ function cleanupOrphans() {
365
403
  // imported lib/task-manager (directly or via lib/pipeline) starts its
366
404
  // own setInterval task runner that never exits — those run in parallel
367
405
  // with the real runner and silently steal tasks. Detect via lsof on
368
- // workflow.db, exclude our own next-server + standalones.
406
+ // workflow.db (Mac), with a fuser fallback for lsof-less Linux.
369
407
  try {
370
408
  const dbPath = join(DATA_DIR, 'workflow.db');
371
- const lsofOut = execSync(`lsof -t "${dbPath}"`, { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
372
- for (const pid of lsofOut.split('\n').map(s => s.trim()).filter(Boolean)) {
409
+ let holders = [];
410
+ try {
411
+ const out = execSync(`lsof -t "${dbPath}"`, {
412
+ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
413
+ }).trim();
414
+ holders = out.split('\n').map(s => s.trim()).filter(Boolean);
415
+ } catch {
416
+ try {
417
+ // `fuser <file>` writes pids to stderr, not stdout. Merge streams.
418
+ const out = execSync(`fuser "${dbPath}" 2>&1`, {
419
+ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
420
+ });
421
+ holders = out.replace(/^.*?:/, '').trim().split(/\s+/).filter(p => /^\d+$/.test(p));
422
+ } catch { /* both unavailable — zombie scan disabled */ }
423
+ }
424
+ for (const pid of holders) {
373
425
  if (pid === myPid || protectedPids.has(pid)) continue;
374
426
  let cmd = '';
375
427
  try {
@@ -458,25 +510,35 @@ async function stopServer() {
458
510
  } catch {}
459
511
  try { unlinkSync(PID_FILE); } catch {}
460
512
 
461
- // Also kill by port (in case PID file is stale)
462
- const portPids = [];
463
- try {
464
- const pids = execSync(`lsof -ti:${webPort}`, { encoding: 'utf-8', timeout: 3000 }).trim();
465
- for (const p of pids.split('\n').filter(Boolean)) {
466
- const pid = parseInt(p.trim());
467
- try { process.kill(pid, 'SIGTERM'); stopped = true; portPids.push(pid); } catch {}
468
- }
469
- if (pids) console.log(`[forge] Killed processes on port ${webPort}`);
470
- } catch {}
513
+ // Also kill by port (in case PID file is stale). Use findPortPids
514
+ // so Linux-without-lsof still works.
515
+ const portPids = findPortPids(webPort);
516
+ for (const p of portPids) {
517
+ const pid = parseInt(p);
518
+ try { process.kill(pid, 'SIGTERM'); stopped = true; } catch {}
519
+ }
520
+ if (portPids.length > 0) console.log(`[forge] Killed processes on port ${webPort}`);
471
521
 
472
- // Force kill after 2 seconds if SIGTERM didn't work
522
+ // Force kill survivors after 2 seconds.
473
523
  if (portPids.length > 0) {
474
524
  await new Promise(r => setTimeout(r, 2000));
475
525
  for (const pid of portPids) {
476
- try { process.kill(pid, 'SIGKILL'); } catch {}
526
+ try { process.kill(parseInt(pid), 'SIGKILL'); } catch {}
477
527
  }
478
528
  }
479
529
 
530
+ // Final verify — if anything still listens on the port (e.g. a child
531
+ // re-bound, or our SIGKILL hit EPERM), surface it loudly. Silent leak
532
+ // is exactly the bug we're fixing.
533
+ const survivors = findPortPids(webPort);
534
+ if (survivors.length > 0) {
535
+ console.warn(
536
+ `[forge] WARNING: port ${webPort} still bound by pid(s) ${survivors.join(', ')} after stop. ` +
537
+ `Likely a different user / cron-launched / sudo'd instance. ` +
538
+ `Run: kill ${survivors.join(' ')} (or with sudo) to free it.`,
539
+ );
540
+ }
541
+
480
542
  if (!stopped) {
481
543
  console.log('[forge] No running server found');
482
544
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.39",
3
+ "version": "0.10.40",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {