@venturewild/workspace 0.3.4 → 0.3.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -255,12 +255,16 @@ export class AutoUpdater {
255
255
  /**
256
256
  * One auto-update cycle, called on the supervisor's slow timer. Self-rate-limits
257
257
  * via dueForCheck so the timer cadence and the check interval are independent.
258
- * Returns a short status string (exposed for tests/logging).
258
+ * `force` bypasses dueForCheck used by the supervisor's post-boot kick so a
259
+ * RESTART always checks for updates immediately (a reboot is a rare, deliberate
260
+ * signal; without this a reboot within the 6h window never re-checked — why a
261
+ * sleepy/rebooted Mac could sit on a stale version). Returns a short status
262
+ * string (exposed for tests/logging).
259
263
  */
260
- async tick() {
264
+ async tick({ force = false } = {}) {
261
265
  if (this.inProgress) return 'busy';
262
266
  if (!this.enabled()) return 'disabled';
263
- if (!this.dueForCheck()) return 'not-due';
267
+ if (!force && !this.dueForCheck()) return 'not-due';
264
268
  this.inProgress = true;
265
269
  try {
266
270
  touchLastCheck(this.globalDir, this.nowImpl());
@@ -65,6 +65,9 @@ export class DaemonSupervisor {
65
65
  this.globalDir = globalDir;
66
66
  this.pidFile = path.join(globalDir, 'daemon.pid');
67
67
  this.logFile = path.join(globalDir, 'daemon.log');
68
+ // Serializes concurrent ensureRunning() spawns (server + supervisor) so only
69
+ // one daemon is started — prevents the duplicate-daemon :8320 bind conflict.
70
+ this.spawnLockFile = path.join(globalDir, 'daemon-spawn.lock');
68
71
  this.resolveBinary = resolveBinary;
69
72
  this.spawnImpl = spawnImpl;
70
73
  this.fetchImpl = fetchImpl;
@@ -89,6 +92,14 @@ export class DaemonSupervisor {
89
92
  /**
90
93
  * Start the daemon unless it is already up. Idempotent and best-effort —
91
94
  * the result is reported, never thrown.
95
+ *
96
+ * Concurrency-safe: the daemon is ensured by BOTH the server (its DaemonBridge)
97
+ * and the always-on supervisor (daemonTick). At boot neither sees the daemon up
98
+ * yet, so without a guard they'd BOTH spawn → two daemons fighting over :8320
99
+ * (`Address already in use`) and both opening proxy links → the relay evicts one
100
+ * → reconnect churn. An atomic cross-process spawn lock serializes the decision:
101
+ * the winner re-checks health then spawns; the loser waits for the winner's
102
+ * daemon instead of spawning its own.
92
103
  * @returns {Promise<{started:boolean, alreadyRunning?:boolean, pid?:number,
93
104
  * error?:string}>}
94
105
  */
@@ -96,7 +107,50 @@ export class DaemonSupervisor {
96
107
  if ((await this.health()).running) {
97
108
  return { started: false, alreadyRunning: true };
98
109
  }
99
- return this.spawnDaemon();
110
+ if (!this.acquireSpawnLock()) {
111
+ // Another process (server bridge / always-on supervisor) is spawning —
112
+ // wait for ITS daemon rather than racing in a second one.
113
+ const up = await this.waitForHealthy(4000);
114
+ return up ? { started: false, alreadyRunning: true } : { started: false, error: 'spawn-in-progress' };
115
+ }
116
+ try {
117
+ // Re-check under the lock: the other caller may have just brought it up.
118
+ if ((await this.health()).running) {
119
+ return { started: false, alreadyRunning: true };
120
+ }
121
+ return this.spawnDaemon();
122
+ } finally {
123
+ this.releaseSpawnLock();
124
+ }
125
+ }
126
+
127
+ /** Is a pid alive? EPERM ("exists, not ours") still counts as alive. */
128
+ pidAlive(pid) {
129
+ try { this.killImpl(pid, 0); return true; } catch (e) { return !!(e && e.code === 'EPERM'); }
130
+ }
131
+
132
+ /**
133
+ * Atomic, cross-process spawn lock (shared globalDir, so it serializes the
134
+ * server and the supervisor). Returns true iff we hold it. A stale lock whose
135
+ * recorded pid is dead is taken over — a crash mid-spawn can't wedge it.
136
+ */
137
+ acquireSpawnLock() {
138
+ try { mkdirSync(this.globalDir, { recursive: true }); } catch { /* surfaced below */ }
139
+ try {
140
+ writeFileSync(this.spawnLockFile, String(process.pid), { flag: 'wx' }); // atomic exclusive create
141
+ return true;
142
+ } catch {
143
+ let holder = null;
144
+ try { holder = Number(readFileSync(this.spawnLockFile, 'utf8').trim()); } catch { /* unreadable */ }
145
+ if (holder && this.pidAlive(holder)) return false; // a live caller is spawning
146
+ try { writeFileSync(this.spawnLockFile, String(process.pid)); return true; } catch { return false; }
147
+ }
148
+ }
149
+
150
+ releaseSpawnLock() {
151
+ try {
152
+ if (Number(readFileSync(this.spawnLockFile, 'utf8').trim()) === process.pid) unlinkSync(this.spawnLockFile);
153
+ } catch { /* already gone */ }
100
154
  }
101
155
 
102
156
  /** Spawn the daemon detached + window-hidden, logging to `daemon.log`. */
@@ -503,9 +503,9 @@ export class WorkspaceSupervisor {
503
503
  });
504
504
  }
505
505
 
506
- runUpdateTick() {
506
+ runUpdateTick(opts = {}) {
507
507
  if (!this.autoUpdater) return;
508
- this.autoUpdater.tick()
508
+ this.autoUpdater.tick(opts)
509
509
  .then((r) => { if (r && !['not-due', 'disabled', 'up-to-date', 'busy'].includes(r)) this.log(`auto-update tick: ${r}`); })
510
510
  .catch((e) => this.log(`auto-update error: ${e?.message || e}`));
511
511
  }
@@ -617,7 +617,10 @@ export class WorkspaceSupervisor {
617
617
  this.autoUpdater = u;
618
618
  this.updateTimer = setInterval(() => this.runUpdateTick(), this.updatePollMs);
619
619
  if (this.updateTimer.unref) this.updateTimer.unref();
620
- const kick = setTimeout(() => this.runUpdateTick(), 60_000);
620
+ // FORCE the first post-boot check (bypass the 6h dueForCheck): a restart
621
+ // should always pull the latest, so a rebooted/woken machine can't sit on
622
+ // a stale version just because it last checked <6h ago.
623
+ const kick = setTimeout(() => this.runUpdateTick({ force: true }), 60_000);
621
624
  if (kick.unref) kick.unref();
622
625
  }).catch((e) => this.log(`auto-update init error: ${e?.message || e}`));
623
626
  }