quilltap 4.10.0-dev.8 → 4.10.0-dev.9

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.
Files changed (2) hide show
  1. package/bin/quilltap.js +55 -48
  2. package/package.json +1 -1
package/bin/quilltap.js CHANGED
@@ -484,6 +484,39 @@ function verifyPidIsNode(pid, expectedArgv0) {
484
484
  }
485
485
  }
486
486
 
487
+ const HEARTBEAT_FRESH_MS = 5 * 60 * 1000;
488
+
489
+ /**
490
+ * Decide whether a lock record belongs to a live process.
491
+ *
492
+ * A hostname that differs from ours does NOT mean a different machine: macOS
493
+ * derives gethostname() dynamically when `scutil --get HostName` is unset, so
494
+ * one Mac reports e.g. "MacBook-Pro.local" and "Mac" at different times. So
495
+ * check PID liveness regardless of the recorded name, and fall back to
496
+ * heartbeat freshness for any environment rather than only for containers.
497
+ * See bug 126.
498
+ */
499
+ function assessLock(lock, hostname) {
500
+ const sameHost = lock.hostname === hostname;
501
+ const alive = isPidAlive(lock.pid);
502
+ const isNode = alive ? verifyPidIsNode(lock.pid, lock.processArgv0 || '') : false;
503
+ const heartbeatAgeMs = lock.lastHeartbeat
504
+ ? Date.now() - new Date(lock.lastHeartbeat).getTime()
505
+ : Infinity;
506
+ const heartbeatFresh = heartbeatAgeMs < HEARTBEAT_FRESH_MS;
507
+
508
+ return {
509
+ sameHost,
510
+ alive,
511
+ isNode,
512
+ heartbeatAgeMs,
513
+ heartbeatFresh,
514
+ // Held by something running: a confirmed local process, or any holder
515
+ // still refreshing its heartbeat.
516
+ live: (alive && isNode) || heartbeatFresh,
517
+ };
518
+ }
519
+
487
520
  /**
488
521
  * Handle --lock-status, --lock-clean, --lock-override commands.
489
522
  */
@@ -518,32 +551,20 @@ function handleLockCommand(dataDir, opts) {
518
551
  return;
519
552
  }
520
553
 
521
- const sameHost = lock.hostname === hostname;
522
- const alive = sameHost && isPidAlive(lock.pid);
523
- const isNode = alive ? verifyPidIsNode(lock.pid, lock.processArgv0 || '') : false;
554
+ const { sameHost, alive, isNode, heartbeatAgeMs, heartbeatFresh } =
555
+ assessLock(lock, hostname);
524
556
 
525
557
  // Status line
526
558
  let status;
527
559
  if (alive && isNode) {
528
560
  status = '\x1b[32mACTIVE\x1b[0m (process confirmed running)';
561
+ } else if (heartbeatFresh) {
562
+ const ageStr = Math.round(heartbeatAgeMs / 1000) + 's';
563
+ status = `\x1b[32mACTIVE\x1b[0m (${lock.environment || 'unknown'}, heartbeat ${ageStr} ago)`;
529
564
  } else if (alive && !isNode) {
530
565
  status = '\x1b[33mSUSPECT\x1b[0m (PID alive but does not look like Quilltap — possible PID reuse)';
531
566
  } else if (!sameHost) {
532
- // Different hostname — could be a container on this machine
533
- const isVMOrContainer = lock.environment === 'docker';
534
- const heartbeatAgeMs = lock.lastHeartbeat
535
- ? Date.now() - new Date(lock.lastHeartbeat).getTime()
536
- : Infinity;
537
- const heartbeatFreshMs = 5 * 60 * 1000;
538
-
539
- if (isVMOrContainer && heartbeatAgeMs < heartbeatFreshMs) {
540
- const ageStr = Math.round(heartbeatAgeMs / 1000) + 's';
541
- status = `\x1b[32mACTIVE (${lock.environment}, heartbeat ${ageStr} ago)\x1b[0m`;
542
- } else if (isVMOrContainer) {
543
- status = `\x1b[33mSTALE (${lock.environment}, no recent heartbeat)\x1b[0m — will be auto-claimed on next startup`;
544
- } else {
545
- status = '\x1b[33mSTALE (different host)\x1b[0m — will be auto-claimed on next startup';
546
- }
567
+ status = `\x1b[33mSTALE (${lock.environment || 'unknown'} on ${lock.hostname}, no recent heartbeat)\x1b[0m — will be auto-claimed on next startup`;
547
568
  } else {
548
569
  status = '\x1b[31mSTALE (process dead)\x1b[0m — will be auto-claimed on next startup';
549
570
  }
@@ -551,7 +572,7 @@ function handleLockCommand(dataDir, opts) {
551
572
  console.log(`Instance Lock Status: ${status}`);
552
573
  console.log();
553
574
  console.log(` PID: ${lock.pid}`);
554
- console.log(` Hostname: ${lock.hostname}${sameHost ? ' (this host)' : ' (different host)'}`);
575
+ console.log(` Hostname: ${lock.hostname}${sameHost ? ' (this host)' : ` (recorded name differs from ours: ${hostname})`}`);
555
576
  console.log(` Environment: ${lock.environment || 'unknown'}`);
556
577
  console.log(` Process: ${lock.processTitle || 'unknown'}`);
557
578
  console.log(` Started: ${lock.startedAt || 'unknown'}`);
@@ -599,37 +620,23 @@ function handleLockCommand(dataDir, opts) {
599
620
  return;
600
621
  }
601
622
 
602
- const sameHost = lock.hostname === hostname;
603
- const alive = sameHost && isPidAlive(lock.pid);
623
+ const { sameHost, alive, isNode, heartbeatAgeMs, heartbeatFresh } =
624
+ assessLock(lock, hostname);
604
625
 
605
- if (alive) {
606
- const isNode = verifyPidIsNode(lock.pid, lock.processArgv0 || '');
607
- if (isNode) {
608
- console.log(`Lock is held by a live Quilltap process (PID ${lock.pid}). Cannot clean.`);
609
- console.log('Stop the running instance first, or use --lock-override to force.');
610
- process.exit(1);
611
- } else {
612
- console.log(`Lock references PID ${lock.pid} which is alive but does NOT look like a Quilltap process.`);
613
- console.log('This is likely a stale lock with a reused PID. Removing.');
614
- }
626
+ if (alive && isNode) {
627
+ console.log(`Lock is held by a live Quilltap process (PID ${lock.pid}). Cannot clean.`);
628
+ console.log('Stop the running instance first, or use --lock-override to force.');
629
+ process.exit(1);
630
+ } else if (heartbeatFresh) {
631
+ const ageStr = Math.round(heartbeatAgeMs / 1000) + 's';
632
+ console.log(`Lock is still being refreshed (heartbeat ${ageStr} ago) — its holder is alive. Cannot clean.`);
633
+ console.log('Stop the running instance first, or use --lock-override to force.');
634
+ process.exit(1);
635
+ } else if (alive && !isNode) {
636
+ console.log(`Lock references PID ${lock.pid} which is alive but does NOT look like a Quilltap process.`);
637
+ console.log('This is likely a stale lock with a reused PID. Removing.');
615
638
  } else if (!sameHost) {
616
- // Different hostname — check if it's a container with a recent heartbeat
617
- const isVMOrContainer = lock.environment === 'docker';
618
- const heartbeatAgeMs = lock.lastHeartbeat
619
- ? Date.now() - new Date(lock.lastHeartbeat).getTime()
620
- : Infinity;
621
- const heartbeatFreshMs = 5 * 60 * 1000;
622
-
623
- if (isVMOrContainer && heartbeatAgeMs < heartbeatFreshMs) {
624
- const ageStr = Math.round(heartbeatAgeMs / 1000) + 's';
625
- console.log(`Lock is held by a live ${lock.environment} instance (heartbeat ${ageStr} ago). Cannot clean.`);
626
- console.log('Stop the other instance first, or use --lock-override to force.');
627
- process.exit(1);
628
- } else if (isVMOrContainer) {
629
- console.log(`Lock was held by ${lock.environment} (${lock.hostname}) with no recent heartbeat. Removing stale lock.`);
630
- } else {
631
- console.log(`Lock was held by a different host (${lock.hostname}). Removing stale lock.`);
632
- }
639
+ console.log(`Lock was held by ${lock.environment || 'unknown'} on ${lock.hostname} with no recent heartbeat. Removing stale lock.`);
633
640
  } else {
634
641
  console.log(`Lock was held by PID ${lock.pid} which is no longer running. Removing stale lock.`);
635
642
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.10.0-dev.8",
3
+ "version": "4.10.0-dev.9",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",