groove-dev 0.27.212 → 0.27.214

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.
@@ -0,0 +1,87 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { existsSync, readdirSync } from 'fs';
3
+ import { createRequire } from 'module';
4
+ import { dirname, join } from 'path';
5
+ import { pathToFileURL } from 'url';
6
+
7
+ const port = 31415;
8
+ const projectDir = process.argv[2] || process.cwd();
9
+
10
+ function preflightCheck(daemonPath) {
11
+ if (!existsSync(daemonPath)) {
12
+ throw new Error(
13
+ `Daemon entry point not found at ${daemonPath}. ` +
14
+ 'The app may not have been packaged correctly — try reinstalling Groove.'
15
+ );
16
+ }
17
+
18
+ const daemonDir = dirname(daemonPath);
19
+ const require = createRequire(daemonPath);
20
+ const critical = ['express', 'ws'];
21
+ const missing = critical.filter(dep => {
22
+ try { require.resolve(dep); return false; } catch { return true; }
23
+ });
24
+
25
+ if (missing.length) {
26
+ const diag = [`Missing deps: ${missing.join(', ')}`, `GROOVE_DAEMON_PATH: ${daemonPath}`];
27
+ let walkDir = daemonDir;
28
+ for (let i = 0; i < 5 && walkDir !== dirname(walkDir); i++) {
29
+ try {
30
+ const entries = readdirSync(walkDir);
31
+ diag.push(`${walkDir}/: [${entries.join(', ')}]`);
32
+ } catch { diag.push(`${walkDir}/: (unreadable)`); }
33
+ walkDir = dirname(walkDir);
34
+ }
35
+ throw new Error(
36
+ `Daemon is missing dependencies. ` +
37
+ `Diagnostics:\n${diag.join('\n')}\n` +
38
+ 'The app bundle may be incomplete — try reinstalling Groove.'
39
+ );
40
+ }
41
+ }
42
+
43
+ async function main() {
44
+ let Daemon;
45
+ const daemonPath = process.env.GROOVE_DAEMON_PATH;
46
+
47
+ if (daemonPath) {
48
+ preflightCheck(daemonPath);
49
+ const mod = await import(pathToFileURL(daemonPath).href);
50
+ Daemon = mod.Daemon;
51
+ } else {
52
+ const mod = await import('@groove-dev/daemon');
53
+ Daemon = mod.Daemon;
54
+ }
55
+
56
+ const daemon = new Daemon({ port, projectDir });
57
+ await daemon.start();
58
+
59
+ process.send({ type: 'ready', port: daemon.port });
60
+
61
+ process.on('message', (msg) => {
62
+ if (msg.type === 'auth-token') {
63
+ (async () => {
64
+ try { await daemon.setAuthToken(msg.token); } catch (err) {
65
+ process.stderr.write(`[daemon-bridge] setAuthToken failed: ${err.message}\n`);
66
+ }
67
+ })();
68
+ }
69
+ });
70
+
71
+ process.on('SIGTERM', async () => {
72
+ await daemon.stop();
73
+ process.exit(0);
74
+ });
75
+
76
+ process.on('SIGINT', async () => {
77
+ await daemon.stop();
78
+ process.exit(0);
79
+ });
80
+ }
81
+
82
+ main().catch((err) => {
83
+ if (process.send) {
84
+ process.send({ type: 'error', message: err.message });
85
+ }
86
+ process.exit(1);
87
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.212",
3
+ "version": "0.27.214",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.212",
3
+ "version": "0.27.214",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -20,6 +20,16 @@ const MAX_PORT_ATTEMPTS = 10;
20
20
  const HEALTH_INTERVAL = 30000;
21
21
  const HEALTH_TIMEOUT = 5000;
22
22
  const MAX_FAIL_COUNT = 3;
23
+ // Long-timeout probe used to CONFIRM death before killing a tunnel — a busy
24
+ // remote daemon can sit on /api/health well past the 5s routine probe.
25
+ const CONFIRM_TIMEOUT = 15000;
26
+ // At most one automatic rebuild per tunnel per window; beyond that it stays
27
+ // disconnected rather than thrashing against a host that keeps dying.
28
+ const REBUILD_COOLDOWN_MS = 10 * 60 * 1000;
29
+ // Restarting a crashed REMOTE daemon is cheaper and safer than a rebuild, so
30
+ // its cooldown is shorter — but still bounded: a daemon that dies right after
31
+ // every start has a real problem more starts won't fix.
32
+ const REMOTE_START_COOLDOWN_MS = 2 * 60 * 1000;
23
33
 
24
34
  const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
25
35
 
@@ -54,6 +64,10 @@ export class TunnelManager {
54
64
  constructor(daemon) {
55
65
  this.daemon = daemon;
56
66
  this.remotesPath = resolve(daemon.grooveDir, 'remotes.json');
67
+ // Live tunnel state, persisted separately from the configs so a daemon
68
+ // restart can re-adopt still-running ssh processes instead of forgetting
69
+ // them (the configs file is user data; this is runtime state).
70
+ this.activePath = resolve(daemon.grooveDir, 'tunnels-active.json');
57
71
  this.saved = new Map();
58
72
  this.active = new Map();
59
73
  this._healthInterval = null;
@@ -82,8 +96,9 @@ export class TunnelManager {
82
96
  }
83
97
 
84
98
  async init() {
99
+ await this._readopt();
85
100
  for (const [id, config] of this.saved) {
86
- if (config.autoConnect) {
101
+ if (config.autoConnect && !this.active.has(id)) {
87
102
  try {
88
103
  await this.connect(id);
89
104
  } catch (err) {
@@ -93,6 +108,71 @@ export class TunnelManager {
93
108
  }
94
109
  }
95
110
 
111
+ // Re-adopt tunnels whose detached ssh processes survived a daemon restart.
112
+ // Without this, every daemon/app restart orphaned the ssh (or shutdown killed
113
+ // it) and the new daemon started amnesiac — remote windows died mid-session
114
+ // and the user had to reconnect everything by hand.
115
+ async _readopt() {
116
+ let entries = [];
117
+ try {
118
+ if (existsSync(this.activePath)) entries = JSON.parse(readFileSync(this.activePath, 'utf8'));
119
+ } catch { /* corrupt — treat as none */ }
120
+ if (!Array.isArray(entries) || entries.length === 0) return;
121
+
122
+ let adopted = 0;
123
+ for (const e of entries) {
124
+ if (!e?.id || !e.pid || !e.localPort || !this.saved.has(e.id)) continue;
125
+ // Only re-adopt what is provably OUR ssh still doing THIS job: the pid
126
+ // must be alive, be an ssh process forwarding this port, and the port
127
+ // must serve HTTP.
128
+ let alive = false;
129
+ try { process.kill(e.pid, 0); alive = true; } catch { /* gone */ }
130
+ if (alive) alive = this._looksLikeOurSsh(e.pid, e.localPort);
131
+ if (alive && await this._tunnelResponds(e.localPort)) {
132
+ this.active.set(e.id, {
133
+ pid: e.pid,
134
+ localPort: e.localPort,
135
+ startedAt: e.startedAt || new Date().toISOString(),
136
+ lastPing: Date.now(),
137
+ latencyMs: null,
138
+ healthy: true,
139
+ failCount: 0,
140
+ });
141
+ adopted++;
142
+ const name = this.saved.get(e.id)?.name || e.id;
143
+ console.log(`[Groove:Tunnel] Re-adopted live tunnel to ${name} on port ${e.localPort}`);
144
+ this.daemon.broadcast({ type: 'tunnel.connected', data: { id: e.id, name, localPort: e.localPort, host: this.saved.get(e.id)?.host, url: `http://localhost:${e.localPort}?instance=${encodeURIComponent(name)}` } });
145
+ } else if (alive) {
146
+ // ssh survives but doesn't serve — a corpse from before the restart.
147
+ try { process.kill(e.pid, 'SIGTERM'); } catch { /* gone */ }
148
+ }
149
+ }
150
+ if (adopted > 0 && !this._healthInterval) {
151
+ this._healthInterval = setInterval(() => this._healthCheckAll(), HEALTH_INTERVAL);
152
+ }
153
+ this._saveActive();
154
+ }
155
+
156
+ // Identity check for re-adoption: is this pid an ssh forwarding this port?
157
+ // Guards against pid recycling handing us an unrelated process.
158
+ _looksLikeOurSsh(pid, localPort) {
159
+ try {
160
+ const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
161
+ encoding: 'utf8', timeout: 3000,
162
+ }).trim();
163
+ return cmd.includes('ssh') && cmd.includes(String(localPort));
164
+ } catch { return false; }
165
+ }
166
+
167
+ _saveActive() {
168
+ try {
169
+ const entries = [...this.active.entries()].map(([id, c]) => ({
170
+ id, pid: c.pid, localPort: c.localPort, startedAt: c.startedAt,
171
+ }));
172
+ writeFileSync(this.activePath, JSON.stringify(entries, null, 2), { mode: 0o600 });
173
+ } catch { /* best effort */ }
174
+ }
175
+
96
176
  getSaved() {
97
177
  return Array.from(this.saved.values()).map(s => ({
98
178
  ...this._sanitize(s),
@@ -295,6 +375,8 @@ export class TunnelManager {
295
375
  return { localPort: existing.localPort, pid: existing.pid, name: config.name };
296
376
  }
297
377
  console.log(`[Groove:Tunnel] ${config.name}: existing tunnel is not responding — rebuilding`);
378
+ // Reuse the dead tunnel's port so any GUI window pointed at it heals.
379
+ opts = { ...opts, preferredPort: opts.preferredPort || existing.localPort };
298
380
  await this.disconnect(id);
299
381
  }
300
382
 
@@ -328,7 +410,14 @@ export class TunnelManager {
328
410
  // Establish SSH tunnel
329
411
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
330
412
 
331
- const localPort = await this._findAvailablePort();
413
+ // A rebuild wants its old port back: the remote GUI window is pointed at it
414
+ // and will self-heal over WebSocket retry only if the port stays the same.
415
+ let localPort;
416
+ if (opts.preferredPort && !(await this._isPortInUse(opts.preferredPort))) {
417
+ localPort = opts.preferredPort;
418
+ } else {
419
+ localPort = await this._findAvailablePort();
420
+ }
332
421
  const target = `${config.user}@${config.host}`;
333
422
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
334
423
 
@@ -391,6 +480,7 @@ export class TunnelManager {
391
480
  healthy: true,
392
481
  failCount: 0,
393
482
  });
483
+ this._saveActive();
394
484
 
395
485
  // Verify daemon is reachable through tunnel, start if needed
396
486
  let remoteAlive = false;
@@ -445,7 +535,7 @@ export class TunnelManager {
445
535
  // Does the tunnel actually serve a request, as opposed to merely holding an
446
536
  // open listening socket? A wedged forward passes a TCP connect test but never
447
537
  // answers, so only an HTTP round-trip proves it.
448
- async _tunnelResponds(localPort, timeoutMs = HEALTH_TIMEOUT) {
538
+ async _tunnelResponds(localPort, timeoutMs = this.healthTimeout ?? HEALTH_TIMEOUT) {
449
539
  try {
450
540
  const res = await fetch(`http://localhost:${localPort}/api/health`, {
451
541
  signal: AbortSignal.timeout(timeoutMs),
@@ -481,6 +571,7 @@ export class TunnelManager {
481
571
  if (localPort) await this._waitForPortFree(localPort, 3000);
482
572
 
483
573
  this.active.delete(id);
574
+ this._saveActive();
484
575
 
485
576
  const config = this.saved.get(id);
486
577
  this.daemon.audit.log('tunnel.disconnect', { id, name: config?.name });
@@ -915,22 +1006,26 @@ export class TunnelManager {
915
1006
 
916
1007
  async _healthCheckPass() {
917
1008
  // Timers don't fire while the machine is asleep, so an interval that should
918
- // have run every HEALTH_INTERVAL arriving far later means we just woke up.
919
- // Every tunnel is suspect at that point: verify them now rather than waiting
920
- // for MAX_FAIL_COUNT normal cycles to notice.
1009
+ // have run every HEALTH_INTERVAL arriving far later means we PROBABLY just
1010
+ // woke up but not certainly: this daemon also blocks its event loop for
1011
+ // long stretches (execFileSync ssh calls in test/upgrade paths), which
1012
+ // produces the same gap on a machine that never slept. So a gap only makes
1013
+ // tunnels *suspect* — it fast-tracks them to the confirmation ladder below.
1014
+ // It must never lower the bar for killing one (that misdiagnosis dropped a
1015
+ // healthy DGX tunnel twice in ten minutes).
921
1016
  const now = Date.now();
922
1017
  const gap = now - (this._lastHealthCheck || now);
923
1018
  this._lastHealthCheck = now;
924
- const wokeFromSleep = gap > HEALTH_INTERVAL * 3;
925
- if (wokeFromSleep && this.active.size > 0) {
926
- console.log(`[Groove:Tunnel] Detected a ${Math.round(gap / 1000)}s gap (system sleep) — verifying tunnels`);
1019
+ const suspectAll = gap > HEALTH_INTERVAL * 3;
1020
+ if (suspectAll && this.active.size > 0) {
1021
+ console.log(`[Groove:Tunnel] ${Math.round(gap / 1000)}s timer gap (sleep or blocked loop) — verifying tunnels`);
927
1022
  }
928
1023
 
929
1024
  for (const [id, conn] of this.active) {
930
1025
  try {
931
1026
  const start = Date.now();
932
1027
  const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
933
- signal: AbortSignal.timeout(HEALTH_TIMEOUT),
1028
+ signal: AbortSignal.timeout(this.healthTimeout ?? HEALTH_TIMEOUT),
934
1029
  });
935
1030
  if (res.ok) {
936
1031
  conn.latencyMs = Date.now() - start;
@@ -942,22 +1037,35 @@ export class TunnelManager {
942
1037
  }
943
1038
  } catch {
944
1039
  conn.failCount = (conn.failCount || 0) + 1;
945
- // After a sleep gap, one failure is enough the tunnel was almost
946
- // certainly cut with the network.
947
- const limit = wokeFromSleep ? 1 : MAX_FAIL_COUNT;
948
- if (conn.failCount >= limit) {
949
- conn.healthy = false;
950
- this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
951
-
952
- // Reap it. Previously a dead tunnel was only FLAGGED, so it stayed in
953
- // `active` indefinitely and every later connect() handed back its dead
954
- // port — the reason reconnecting never helped and only a full restart
955
- // did. Removing it means the next connect() builds a real tunnel.
956
- if (conn.failCount >= limit + 1) {
957
- console.log(`[Groove:Tunnel] Reaping dead tunnel ${id} after ${conn.failCount} failed checks`);
958
- this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'reaping' } });
959
- await this.disconnect(id);
960
- continue;
1040
+ // A failed 5s probe is WEAK evidence: it can't distinguish a dead
1041
+ // tunnel from a remote daemon that's briefly busy or our own blocked
1042
+ // event loop. Never kill on it. Once failures accumulate (or a timer
1043
+ // gap makes everything suspect), run the confirmation ladder, which
1044
+ // can — a healthy verdict there resets the count.
1045
+ if (conn.failCount >= MAX_FAIL_COUNT || suspectAll) {
1046
+ const verdict = await this._confirmDead(conn);
1047
+ if (verdict === 'alive') {
1048
+ conn.failCount = 0;
1049
+ conn.healthy = true;
1050
+ conn._wedgedStreak = 0;
1051
+ } else if (verdict === 'remote-down') {
1052
+ // The tunnel is carrying traffic correctly — the far daemon is what
1053
+ // died (typically mid-upgrade). Rebuild would be useless; start it.
1054
+ conn.healthy = false;
1055
+ this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
1056
+ await this._startRemoteDaemon(id, conn);
1057
+ } else {
1058
+ conn.healthy = false;
1059
+ this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
1060
+ // 'wedged' (port accepts, HTTP silent even at long timeout) is the
1061
+ // one verdict with a false-positive path — a remote event loop
1062
+ // blocked 15s+ — so demand it twice in a row. proc-dead/port-dead
1063
+ // are unambiguous: the ssh client is gone or nothing is listening.
1064
+ conn._wedgedStreak = verdict === 'wedged' ? (conn._wedgedStreak || 0) + 1 : 0;
1065
+ if (verdict !== 'wedged' || conn._wedgedStreak >= 2) {
1066
+ await this._reapAndRebuild(id, conn, verdict);
1067
+ continue;
1068
+ }
961
1069
  }
962
1070
  }
963
1071
  }
@@ -968,6 +1076,103 @@ export class TunnelManager {
968
1076
  }
969
1077
  }
970
1078
 
1079
+ // Escalating evidence that a tunnel is actually dead, not merely slow:
1080
+ // 'alive' — answered a long-timeout HTTP probe; leave it alone
1081
+ // 'proc-dead' — the ssh client process is gone
1082
+ // 'port-dead' — nothing is listening on the local port
1083
+ // 'remote-down' — the tunnel forwards fine but the REMOTE end refuses:
1084
+ // the probe fails fast with a connection error, not a
1085
+ // timeout. Killing the tunnel won't fix that — the remote
1086
+ // daemon needs starting (e.g. it died during an upgrade).
1087
+ // 'wedged' — port accepts TCP but HTTP hangs to timeout (dead forward)
1088
+ async _confirmDead(conn) {
1089
+ const started = Date.now();
1090
+ let probeErr = null;
1091
+ try {
1092
+ const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
1093
+ signal: AbortSignal.timeout(this.confirmTimeout ?? CONFIRM_TIMEOUT),
1094
+ });
1095
+ if (res.ok) return 'alive';
1096
+ } catch (err) { probeErr = err; }
1097
+
1098
+ if (conn.pid) {
1099
+ try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
1100
+ }
1101
+ if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
1102
+
1103
+ // ssh is alive and its port listens. A hang (timeout) means the forward is
1104
+ // dead; a FAST connection-level error means ssh relayed the remote side's
1105
+ // refusal — the tunnel works, the far daemon doesn't.
1106
+ const failedFast = Date.now() - started < 2000;
1107
+ const timedOut = probeErr && (probeErr.name === 'TimeoutError' || probeErr.name === 'AbortError');
1108
+ if (failedFast && !timedOut) return 'remote-down';
1109
+ return 'wedged';
1110
+ }
1111
+
1112
+ // The tunnel is healthy; the daemon on the far side is what's down. Start it
1113
+ // over ssh rather than pointlessly rebuilding the tunnel. Rate-limited: if
1114
+ // the remote daemon won't stay up, repeated starts won't save it.
1115
+ async _startRemoteDaemon(id, conn) {
1116
+ this._remoteStartAt = this._remoteStartAt || new Map();
1117
+ const last = this._remoteStartAt.get(id) || 0;
1118
+ if (Date.now() - last < REMOTE_START_COOLDOWN_MS) return;
1119
+ this._remoteStartAt.set(id, Date.now());
1120
+
1121
+ console.log(`[Groove:Tunnel] ${id}: tunnel is fine but the remote daemon is down — starting it`);
1122
+ this.daemon.audit.log('tunnel.remote-daemon-start', { id });
1123
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
1124
+ try {
1125
+ await this.autoStart(id);
1126
+ // Confirm it came up; a success resets the failure counters immediately
1127
+ // instead of waiting out another health cycle.
1128
+ for (let i = 0; i < 10; i++) {
1129
+ await new Promise((r) => setTimeout(r, 2000));
1130
+ if (await this._tunnelResponds(conn.localPort)) {
1131
+ conn.failCount = 0;
1132
+ conn.healthy = true;
1133
+ conn._wedgedStreak = 0;
1134
+ console.log(`[Groove:Tunnel] ${id}: remote daemon is back`);
1135
+ this.daemon.broadcast({ type: 'tunnel.health', data: { id, latencyMs: conn.latencyMs, healthy: true } });
1136
+ return;
1137
+ }
1138
+ }
1139
+ console.warn(`[Groove:Tunnel] ${id}: remote daemon did not come back after start`);
1140
+ } catch (err) {
1141
+ console.warn(`[Groove:Tunnel] ${id}: could not start remote daemon: ${err.message}`);
1142
+ }
1143
+ }
1144
+
1145
+ // Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
1146
+ // local port. The remote GUI window points at that port and its WebSocket
1147
+ // retries every 2s, so a same-port rebuild heals an open window without the
1148
+ // user noticing. Only if the rebuild fails does this surface as a disconnect.
1149
+ // Rate-limited so a genuinely dead host degrades to disconnected instead of
1150
+ // thrashing reconnect attempts forever.
1151
+ async _reapAndRebuild(id, conn, reason) {
1152
+ const { localPort } = conn;
1153
+ console.log(`[Groove:Tunnel] Tunnel ${id} confirmed dead (${reason}) — rebuilding`);
1154
+ this.daemon.audit.log('tunnel.reap', { id, reason, failCount: conn.failCount });
1155
+ await this.disconnect(id);
1156
+
1157
+ const lastRebuild = this._rebuildAt?.get(id) || 0;
1158
+ if (Date.now() - lastRebuild < REBUILD_COOLDOWN_MS) {
1159
+ console.log(`[Groove:Tunnel] ${id} already auto-rebuilt recently — leaving disconnected`);
1160
+ return;
1161
+ }
1162
+ this._rebuildAt = this._rebuildAt || new Map();
1163
+ this._rebuildAt.set(id, Date.now());
1164
+
1165
+ try {
1166
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'reconnecting' } });
1167
+ await this.connect(id, { preferredPort: localPort });
1168
+ console.log(`[Groove:Tunnel] ${id} rebuilt on port ${localPort}`);
1169
+ } catch (err) {
1170
+ console.warn(`[Groove:Tunnel] Auto-rebuild of ${id} failed: ${err.message}`);
1171
+ // disconnect() above already broadcast tunnel.disconnected — the GUI is
1172
+ // consistent; the user can reconnect manually when the host is back.
1173
+ }
1174
+ }
1175
+
971
1176
  // Signal 0 only tests for existence — no signal is delivered.
972
1177
  async _waitForExit(pid, timeoutMs) {
973
1178
  const deadline = Date.now() + timeoutMs;
@@ -1004,25 +1209,17 @@ export class TunnelManager {
1004
1209
  throw new Error(`No available local port found (tried ${DEFAULT_LOCAL_PORT}-${DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS - 1})`);
1005
1210
  }
1006
1211
 
1212
+ // Deliberately does NOT kill the ssh processes. They are spawned detached and
1213
+ // are the user's live sessions: killing them on every daemon restart (app
1214
+ // upgrade, promote, crash) is what nuked remote windows mid-session. State is
1215
+ // persisted; the next daemon re-adopts whatever is still alive and serving.
1216
+ // Explicit disconnect()/delete() remain the paths that actually kill a tunnel.
1007
1217
  shutdown() {
1008
1218
  if (this._healthInterval) {
1009
1219
  clearInterval(this._healthInterval);
1010
1220
  this._healthInterval = null;
1011
1221
  }
1012
- for (const [id] of this.active) {
1013
- try {
1014
- const conn = this.active.get(id);
1015
- if (conn?.pid) {
1016
- const cmd = execFileSync('ps', ['-p', String(conn.pid), '-o', 'command='], {
1017
- encoding: 'utf8',
1018
- timeout: 3000,
1019
- }).trim();
1020
- if (cmd.includes('ssh')) {
1021
- process.kill(conn.pid, 'SIGTERM');
1022
- }
1023
- }
1024
- } catch { /* ignore */ }
1025
- }
1222
+ this._saveActive();
1026
1223
  this.active.clear();
1027
1224
  }
1028
1225
  }
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { describe, it, beforeEach, afterEach } from 'node:test';
12
12
  import assert from 'node:assert/strict';
13
- import { mkdtempSync, rmSync } from 'fs';
13
+ import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs';
14
14
  import { tmpdir } from 'os';
15
15
  import { resolve } from 'path';
16
16
  import { createServer } from 'net';
@@ -66,6 +66,10 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
66
66
  grooveDir = mkdtempSync(resolve(tmpdir(), 'groove-tunnel-'));
67
67
  daemon = makeDaemon(grooveDir);
68
68
  mgr = new TunnelManager(daemon);
69
+ // Shrink probe timeouts (production: 5s routine / 15s confirm) so the
70
+ // wedged-listener tests don't burn real minutes waiting them out.
71
+ mgr.healthTimeout = 400;
72
+ mgr.confirmTimeout = 1200;
69
73
  });
70
74
 
71
75
  afterEach(() => {
@@ -84,7 +88,7 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
84
88
  it('_tunnelResponds rejects a tunnel that accepts but never answers', async () => {
85
89
  const wedged = await startWedgedListener();
86
90
  try {
87
- assert.equal(await mgr._tunnelResponds(wedged.port, 1500), false);
91
+ assert.equal(await mgr._tunnelResponds(wedged.port, 800), false);
88
92
  } finally { wedged.close(); }
89
93
  });
90
94
 
@@ -132,19 +136,25 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
132
136
  } finally { live.close(); }
133
137
  });
134
138
 
135
- it('the health pass reaps a dead tunnel so the next connect starts clean', async () => {
139
+ it('the health pass reaps a proc-dead tunnel and tries to rebuild it', async () => {
136
140
  const wedged = await startWedgedListener();
137
141
  try {
138
142
  mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: 'example.invalid', user: 'ops', port: 22 });
139
143
  mgr.active.set('s19', {
140
- pid: 999999, localPort: wedged.port, healthy: true,
144
+ pid: 999999, // no such process → verdict 'proc-dead', reap on first confirm
145
+ localPort: wedged.port, healthy: true,
141
146
  failCount: 99, // already past the failure limit
142
147
  startedAt: new Date().toISOString(),
143
148
  });
144
149
 
150
+ const rebuilds = [];
151
+ mgr.connect = async (id, opts) => { rebuilds.push({ id, opts }); throw new Error('host unreachable'); };
152
+
145
153
  await mgr._healthCheckAll();
146
154
 
147
155
  assert.equal(mgr.active.has('s19'), false, 'dead tunnel removed from active');
156
+ assert.equal(rebuilds.length, 1, 'an automatic rebuild was attempted');
157
+ assert.equal(rebuilds[0].opts.preferredPort, wedged.port, 'rebuild asks for the same port');
148
158
  assert.ok(
149
159
  daemon.broadcasts.some((b) => b.type === 'tunnel.unhealthy'),
150
160
  'the GUI is told the tunnel went unhealthy',
@@ -152,24 +162,182 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
152
162
  } finally { wedged.close(); }
153
163
  });
154
164
 
155
- it('treats a long timer gap as a sleep and drops the failure tolerance', async () => {
165
+ it('a slow-but-alive tunnel is never reaped the long confirm probe clears it', async () => {
166
+ // Answers /api/health slower than the routine probe allows but inside the
167
+ // confirmation window. This is a busy DGX, not a dead tunnel.
168
+ const sockets = [];
169
+ const slow = createServer((sock) => {
170
+ sockets.push(sock);
171
+ sock.on('data', () => setTimeout(() => {
172
+ const body = '{"ok":true}';
173
+ try {
174
+ sock.end('HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n'
175
+ + `Content-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`);
176
+ } catch { /* closed */ }
177
+ }, 700));
178
+ });
179
+ await new Promise((r) => slow.listen(0, '127.0.0.1', r));
180
+ const port = slow.address().port;
181
+ try {
182
+ mgr.saved.set('dgx', { id: 'dgx', name: 'Axom Spark', host: 'edgexpert.local', user: 'rok', port: 22 });
183
+ mgr.active.set('dgx', {
184
+ pid: process.pid, // definitely alive
185
+ localPort: port, healthy: true, failCount: 99,
186
+ startedAt: new Date().toISOString(),
187
+ });
188
+
189
+ mgr.connect = async () => { throw new Error('rebuild must not be attempted'); };
190
+ await mgr._healthCheckAll();
191
+
192
+ const conn = mgr.active.get('dgx');
193
+ assert.ok(conn, 'the tunnel survived');
194
+ assert.equal(conn.healthy, true, 'confirmed alive by the long probe');
195
+ assert.equal(conn.failCount, 0, 'failure count reset');
196
+ } finally {
197
+ for (const s of sockets) s.destroy();
198
+ slow.close();
199
+ }
200
+ });
201
+
202
+ it('a wedged tunnel needs two consecutive confirmations before it is reaped', { timeout: 30000 }, async () => {
156
203
  const wedged = await startWedgedListener();
157
204
  try {
158
205
  mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: 'example.invalid', user: 'ops', port: 22 });
159
206
  mgr.active.set('s19', {
160
- pid: 999999, localPort: wedged.port, healthy: true, failCount: 0,
207
+ pid: process.pid, // ssh "alive" → verdict is 'wedged', not 'proc-dead'
208
+ localPort: wedged.port, healthy: true, failCount: 99,
161
209
  startedAt: new Date().toISOString(),
162
210
  });
211
+ mgr.connect = async () => { throw new Error('unreachable'); };
163
212
 
164
- // Last check was 10 minutes ago — the machine was asleep.
165
- mgr._lastHealthCheck = Date.now() - 10 * 60 * 1000;
166
213
  await mgr._healthCheckAll();
214
+ assert.equal(mgr.active.has('s19'), true, 'first wedged verdict only flags it');
215
+ assert.equal(mgr.active.get('s19').healthy, false);
167
216
 
168
- const conn = mgr.active.get('s19');
169
- assert.equal(conn?.healthy, false, 'one failure after a sleep gap is enough');
217
+ mgr.active.get('s19').failCount = 99; // still failing next tick
218
+ await mgr._healthCheckAll();
219
+ assert.equal(mgr.active.has('s19'), false, 'second wedged verdict reaps it');
170
220
  } finally { wedged.close(); }
171
221
  });
172
222
 
223
+ it('a timer gap fast-tracks confirmation but cannot kill a live tunnel', async () => {
224
+ const live = await startHealthyListener();
225
+ try {
226
+ mgr.saved.set('dgx', { id: 'dgx', name: 'Axom Spark', host: 'edgexpert.local', user: 'rok', port: 22 });
227
+ mgr.active.set('dgx', {
228
+ pid: process.pid, localPort: live.port, healthy: true, failCount: 0,
229
+ startedAt: new Date().toISOString(),
230
+ });
231
+
232
+ // 10-minute gap: sleep — or just a blocked event loop on a busy daemon.
233
+ mgr._lastHealthCheck = Date.now() - 10 * 60 * 1000;
234
+ mgr.connect = async () => { throw new Error('rebuild must not be attempted'); };
235
+ await mgr._healthCheckAll();
236
+
237
+ const conn = mgr.active.get('dgx');
238
+ assert.ok(conn, 'live tunnel survived the suspicious gap');
239
+ assert.equal(conn.healthy, true);
240
+ } finally { live.close(); }
241
+ });
242
+
243
+ it('remote-down (fast refusal through a live tunnel) starts the daemon, not a rebuild', async () => {
244
+ // ssh alive + port listens + connections REFUSED at the far end: ssh relays
245
+ // the refusal as an immediate close, so the probe fails fast rather than
246
+ // hanging. The tunnel is fine; only the remote daemon needs starting.
247
+ const refusing = createServer((sock) => sock.destroy());
248
+ await new Promise((r) => refusing.listen(0, '127.0.0.1', r));
249
+ const port = refusing.address().port;
250
+ try {
251
+ mgr.saved.set('dgx', { id: 'dgx', name: 'Axom Spark', host: 'edgexpert.local', user: 'axom', port: 22 });
252
+ mgr.active.set('dgx', {
253
+ pid: process.pid, localPort: port, healthy: true, failCount: 99,
254
+ startedAt: new Date().toISOString(),
255
+ });
256
+
257
+ const started = [];
258
+ mgr.autoStart = async (id) => { started.push(id); };
259
+ mgr.connect = async () => { throw new Error('rebuild must not be attempted'); };
260
+
261
+ await mgr._healthCheckAll();
262
+
263
+ assert.deepEqual(started, ['dgx'], 'the remote daemon was started over ssh');
264
+ assert.equal(mgr.active.has('dgx'), true, 'the healthy tunnel was NOT torn down');
265
+ } finally { refusing.close(); }
266
+ });
267
+
268
+ it('re-adopts a surviving tunnel after a daemon restart instead of forgetting it', async () => {
269
+ const live = await startHealthyListener();
270
+ try {
271
+ mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: '3.22.211.238', user: 'ubuntu', port: 22 });
272
+ mgr._save(); // the new daemon loads configs from disk
273
+ mgr.active.set('s19', {
274
+ pid: process.pid, localPort: live.port,
275
+ startedAt: new Date().toISOString(), healthy: true, failCount: 0,
276
+ });
277
+ mgr._saveActive();
278
+ mgr.shutdown(); // daemon going down — must NOT kill the tunnel
279
+
280
+ // "New daemon" after restart. The stand-in pid is node, not ssh, so the
281
+ // identity gate is stubbed — everything else runs for real.
282
+ const daemon2 = makeDaemon(grooveDir);
283
+ const mgr2 = new TunnelManager(daemon2);
284
+ mgr2.healthTimeout = 400;
285
+ mgr2._looksLikeOurSsh = (pid, port) => pid === process.pid && port === live.port;
286
+
287
+ await mgr2._readopt();
288
+
289
+ assert.equal(mgr2.active.has('s19'), true, 'the surviving tunnel was re-adopted');
290
+ assert.equal(mgr2.active.get('s19').localPort, live.port, 'on its original port');
291
+ assert.ok(
292
+ daemon2.broadcasts.some((b) => b.type === 'tunnel.connected'),
293
+ 'the GUI is told the tunnel is (still) connected',
294
+ );
295
+ mgr2.shutdown();
296
+ } finally { live.close(); }
297
+ });
298
+
299
+ it('does not re-adopt a dead or hijacked pid', async () => {
300
+ const live = await startHealthyListener();
301
+ try {
302
+ mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: '3.22.211.238', user: 'ubuntu', port: 22 });
303
+ mgr._save();
304
+ mgr.active.set('s19', {
305
+ pid: 999999, localPort: live.port, // no such process
306
+ startedAt: new Date().toISOString(), healthy: true, failCount: 0,
307
+ });
308
+ mgr._saveActive();
309
+ mgr.shutdown();
310
+
311
+ const daemon2 = makeDaemon(grooveDir);
312
+ const mgr2 = new TunnelManager(daemon2);
313
+ mgr2.healthTimeout = 400;
314
+ await mgr2._readopt();
315
+
316
+ assert.equal(mgr2.active.has('s19'), false, 'a dead pid is not adopted');
317
+ mgr2.shutdown();
318
+ } finally { live.close(); }
319
+ });
320
+
321
+ it('shutdown persists state and does not kill the tunnel process', async () => {
322
+ const live = await startHealthyListener();
323
+ try {
324
+ mgr.saved.set('s19', { id: 's19', name: 'S19', host: 'x', user: 'u', port: 22 });
325
+ mgr.active.set('s19', {
326
+ pid: process.pid, localPort: live.port,
327
+ startedAt: new Date().toISOString(), healthy: true, failCount: 0,
328
+ });
329
+ mgr.shutdown();
330
+
331
+ // Our stand-in "tunnel process" (this test runner) must still be alive —
332
+ // shutdown killing it would have killed the test.
333
+ assert.doesNotThrow(() => process.kill(process.pid, 0));
334
+ const persisted = JSON.parse(readFileSync(resolve(grooveDir, 'tunnels-active.json'), 'utf8'));
335
+ assert.equal(persisted.length, 1);
336
+ assert.equal(persisted[0].id, 's19');
337
+ assert.equal(persisted[0].localPort, live.port);
338
+ } finally { live.close(); }
339
+ });
340
+
173
341
  it('_waitForPortFree reports a released port', async () => {
174
342
  const live = await startHealthyListener();
175
343
  assert.equal(await mgr._waitForPortFree(live.port, 600), false, 'still held while listening');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.212",
3
+ "version": "0.27.214",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.27.212",
3
+ "version": "0.27.214",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.212",
3
+ "version": "0.27.214",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.212",
3
+ "version": "0.27.214",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -20,6 +20,16 @@ const MAX_PORT_ATTEMPTS = 10;
20
20
  const HEALTH_INTERVAL = 30000;
21
21
  const HEALTH_TIMEOUT = 5000;
22
22
  const MAX_FAIL_COUNT = 3;
23
+ // Long-timeout probe used to CONFIRM death before killing a tunnel — a busy
24
+ // remote daemon can sit on /api/health well past the 5s routine probe.
25
+ const CONFIRM_TIMEOUT = 15000;
26
+ // At most one automatic rebuild per tunnel per window; beyond that it stays
27
+ // disconnected rather than thrashing against a host that keeps dying.
28
+ const REBUILD_COOLDOWN_MS = 10 * 60 * 1000;
29
+ // Restarting a crashed REMOTE daemon is cheaper and safer than a rebuild, so
30
+ // its cooldown is shorter — but still bounded: a daemon that dies right after
31
+ // every start has a real problem more starts won't fix.
32
+ const REMOTE_START_COOLDOWN_MS = 2 * 60 * 1000;
23
33
 
24
34
  const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
25
35
 
@@ -54,6 +64,10 @@ export class TunnelManager {
54
64
  constructor(daemon) {
55
65
  this.daemon = daemon;
56
66
  this.remotesPath = resolve(daemon.grooveDir, 'remotes.json');
67
+ // Live tunnel state, persisted separately from the configs so a daemon
68
+ // restart can re-adopt still-running ssh processes instead of forgetting
69
+ // them (the configs file is user data; this is runtime state).
70
+ this.activePath = resolve(daemon.grooveDir, 'tunnels-active.json');
57
71
  this.saved = new Map();
58
72
  this.active = new Map();
59
73
  this._healthInterval = null;
@@ -82,8 +96,9 @@ export class TunnelManager {
82
96
  }
83
97
 
84
98
  async init() {
99
+ await this._readopt();
85
100
  for (const [id, config] of this.saved) {
86
- if (config.autoConnect) {
101
+ if (config.autoConnect && !this.active.has(id)) {
87
102
  try {
88
103
  await this.connect(id);
89
104
  } catch (err) {
@@ -93,6 +108,71 @@ export class TunnelManager {
93
108
  }
94
109
  }
95
110
 
111
+ // Re-adopt tunnels whose detached ssh processes survived a daemon restart.
112
+ // Without this, every daemon/app restart orphaned the ssh (or shutdown killed
113
+ // it) and the new daemon started amnesiac — remote windows died mid-session
114
+ // and the user had to reconnect everything by hand.
115
+ async _readopt() {
116
+ let entries = [];
117
+ try {
118
+ if (existsSync(this.activePath)) entries = JSON.parse(readFileSync(this.activePath, 'utf8'));
119
+ } catch { /* corrupt — treat as none */ }
120
+ if (!Array.isArray(entries) || entries.length === 0) return;
121
+
122
+ let adopted = 0;
123
+ for (const e of entries) {
124
+ if (!e?.id || !e.pid || !e.localPort || !this.saved.has(e.id)) continue;
125
+ // Only re-adopt what is provably OUR ssh still doing THIS job: the pid
126
+ // must be alive, be an ssh process forwarding this port, and the port
127
+ // must serve HTTP.
128
+ let alive = false;
129
+ try { process.kill(e.pid, 0); alive = true; } catch { /* gone */ }
130
+ if (alive) alive = this._looksLikeOurSsh(e.pid, e.localPort);
131
+ if (alive && await this._tunnelResponds(e.localPort)) {
132
+ this.active.set(e.id, {
133
+ pid: e.pid,
134
+ localPort: e.localPort,
135
+ startedAt: e.startedAt || new Date().toISOString(),
136
+ lastPing: Date.now(),
137
+ latencyMs: null,
138
+ healthy: true,
139
+ failCount: 0,
140
+ });
141
+ adopted++;
142
+ const name = this.saved.get(e.id)?.name || e.id;
143
+ console.log(`[Groove:Tunnel] Re-adopted live tunnel to ${name} on port ${e.localPort}`);
144
+ this.daemon.broadcast({ type: 'tunnel.connected', data: { id: e.id, name, localPort: e.localPort, host: this.saved.get(e.id)?.host, url: `http://localhost:${e.localPort}?instance=${encodeURIComponent(name)}` } });
145
+ } else if (alive) {
146
+ // ssh survives but doesn't serve — a corpse from before the restart.
147
+ try { process.kill(e.pid, 'SIGTERM'); } catch { /* gone */ }
148
+ }
149
+ }
150
+ if (adopted > 0 && !this._healthInterval) {
151
+ this._healthInterval = setInterval(() => this._healthCheckAll(), HEALTH_INTERVAL);
152
+ }
153
+ this._saveActive();
154
+ }
155
+
156
+ // Identity check for re-adoption: is this pid an ssh forwarding this port?
157
+ // Guards against pid recycling handing us an unrelated process.
158
+ _looksLikeOurSsh(pid, localPort) {
159
+ try {
160
+ const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
161
+ encoding: 'utf8', timeout: 3000,
162
+ }).trim();
163
+ return cmd.includes('ssh') && cmd.includes(String(localPort));
164
+ } catch { return false; }
165
+ }
166
+
167
+ _saveActive() {
168
+ try {
169
+ const entries = [...this.active.entries()].map(([id, c]) => ({
170
+ id, pid: c.pid, localPort: c.localPort, startedAt: c.startedAt,
171
+ }));
172
+ writeFileSync(this.activePath, JSON.stringify(entries, null, 2), { mode: 0o600 });
173
+ } catch { /* best effort */ }
174
+ }
175
+
96
176
  getSaved() {
97
177
  return Array.from(this.saved.values()).map(s => ({
98
178
  ...this._sanitize(s),
@@ -295,6 +375,8 @@ export class TunnelManager {
295
375
  return { localPort: existing.localPort, pid: existing.pid, name: config.name };
296
376
  }
297
377
  console.log(`[Groove:Tunnel] ${config.name}: existing tunnel is not responding — rebuilding`);
378
+ // Reuse the dead tunnel's port so any GUI window pointed at it heals.
379
+ opts = { ...opts, preferredPort: opts.preferredPort || existing.localPort };
298
380
  await this.disconnect(id);
299
381
  }
300
382
 
@@ -328,7 +410,14 @@ export class TunnelManager {
328
410
  // Establish SSH tunnel
329
411
  this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
330
412
 
331
- const localPort = await this._findAvailablePort();
413
+ // A rebuild wants its old port back: the remote GUI window is pointed at it
414
+ // and will self-heal over WebSocket retry only if the port stays the same.
415
+ let localPort;
416
+ if (opts.preferredPort && !(await this._isPortInUse(opts.preferredPort))) {
417
+ localPort = opts.preferredPort;
418
+ } else {
419
+ localPort = await this._findAvailablePort();
420
+ }
332
421
  const target = `${config.user}@${config.host}`;
333
422
  const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
334
423
 
@@ -391,6 +480,7 @@ export class TunnelManager {
391
480
  healthy: true,
392
481
  failCount: 0,
393
482
  });
483
+ this._saveActive();
394
484
 
395
485
  // Verify daemon is reachable through tunnel, start if needed
396
486
  let remoteAlive = false;
@@ -445,7 +535,7 @@ export class TunnelManager {
445
535
  // Does the tunnel actually serve a request, as opposed to merely holding an
446
536
  // open listening socket? A wedged forward passes a TCP connect test but never
447
537
  // answers, so only an HTTP round-trip proves it.
448
- async _tunnelResponds(localPort, timeoutMs = HEALTH_TIMEOUT) {
538
+ async _tunnelResponds(localPort, timeoutMs = this.healthTimeout ?? HEALTH_TIMEOUT) {
449
539
  try {
450
540
  const res = await fetch(`http://localhost:${localPort}/api/health`, {
451
541
  signal: AbortSignal.timeout(timeoutMs),
@@ -481,6 +571,7 @@ export class TunnelManager {
481
571
  if (localPort) await this._waitForPortFree(localPort, 3000);
482
572
 
483
573
  this.active.delete(id);
574
+ this._saveActive();
484
575
 
485
576
  const config = this.saved.get(id);
486
577
  this.daemon.audit.log('tunnel.disconnect', { id, name: config?.name });
@@ -915,22 +1006,26 @@ export class TunnelManager {
915
1006
 
916
1007
  async _healthCheckPass() {
917
1008
  // Timers don't fire while the machine is asleep, so an interval that should
918
- // have run every HEALTH_INTERVAL arriving far later means we just woke up.
919
- // Every tunnel is suspect at that point: verify them now rather than waiting
920
- // for MAX_FAIL_COUNT normal cycles to notice.
1009
+ // have run every HEALTH_INTERVAL arriving far later means we PROBABLY just
1010
+ // woke up but not certainly: this daemon also blocks its event loop for
1011
+ // long stretches (execFileSync ssh calls in test/upgrade paths), which
1012
+ // produces the same gap on a machine that never slept. So a gap only makes
1013
+ // tunnels *suspect* — it fast-tracks them to the confirmation ladder below.
1014
+ // It must never lower the bar for killing one (that misdiagnosis dropped a
1015
+ // healthy DGX tunnel twice in ten minutes).
921
1016
  const now = Date.now();
922
1017
  const gap = now - (this._lastHealthCheck || now);
923
1018
  this._lastHealthCheck = now;
924
- const wokeFromSleep = gap > HEALTH_INTERVAL * 3;
925
- if (wokeFromSleep && this.active.size > 0) {
926
- console.log(`[Groove:Tunnel] Detected a ${Math.round(gap / 1000)}s gap (system sleep) — verifying tunnels`);
1019
+ const suspectAll = gap > HEALTH_INTERVAL * 3;
1020
+ if (suspectAll && this.active.size > 0) {
1021
+ console.log(`[Groove:Tunnel] ${Math.round(gap / 1000)}s timer gap (sleep or blocked loop) — verifying tunnels`);
927
1022
  }
928
1023
 
929
1024
  for (const [id, conn] of this.active) {
930
1025
  try {
931
1026
  const start = Date.now();
932
1027
  const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
933
- signal: AbortSignal.timeout(HEALTH_TIMEOUT),
1028
+ signal: AbortSignal.timeout(this.healthTimeout ?? HEALTH_TIMEOUT),
934
1029
  });
935
1030
  if (res.ok) {
936
1031
  conn.latencyMs = Date.now() - start;
@@ -942,22 +1037,35 @@ export class TunnelManager {
942
1037
  }
943
1038
  } catch {
944
1039
  conn.failCount = (conn.failCount || 0) + 1;
945
- // After a sleep gap, one failure is enough the tunnel was almost
946
- // certainly cut with the network.
947
- const limit = wokeFromSleep ? 1 : MAX_FAIL_COUNT;
948
- if (conn.failCount >= limit) {
949
- conn.healthy = false;
950
- this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
951
-
952
- // Reap it. Previously a dead tunnel was only FLAGGED, so it stayed in
953
- // `active` indefinitely and every later connect() handed back its dead
954
- // port — the reason reconnecting never helped and only a full restart
955
- // did. Removing it means the next connect() builds a real tunnel.
956
- if (conn.failCount >= limit + 1) {
957
- console.log(`[Groove:Tunnel] Reaping dead tunnel ${id} after ${conn.failCount} failed checks`);
958
- this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'reaping' } });
959
- await this.disconnect(id);
960
- continue;
1040
+ // A failed 5s probe is WEAK evidence: it can't distinguish a dead
1041
+ // tunnel from a remote daemon that's briefly busy or our own blocked
1042
+ // event loop. Never kill on it. Once failures accumulate (or a timer
1043
+ // gap makes everything suspect), run the confirmation ladder, which
1044
+ // can — a healthy verdict there resets the count.
1045
+ if (conn.failCount >= MAX_FAIL_COUNT || suspectAll) {
1046
+ const verdict = await this._confirmDead(conn);
1047
+ if (verdict === 'alive') {
1048
+ conn.failCount = 0;
1049
+ conn.healthy = true;
1050
+ conn._wedgedStreak = 0;
1051
+ } else if (verdict === 'remote-down') {
1052
+ // The tunnel is carrying traffic correctly — the far daemon is what
1053
+ // died (typically mid-upgrade). Rebuild would be useless; start it.
1054
+ conn.healthy = false;
1055
+ this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
1056
+ await this._startRemoteDaemon(id, conn);
1057
+ } else {
1058
+ conn.healthy = false;
1059
+ this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
1060
+ // 'wedged' (port accepts, HTTP silent even at long timeout) is the
1061
+ // one verdict with a false-positive path — a remote event loop
1062
+ // blocked 15s+ — so demand it twice in a row. proc-dead/port-dead
1063
+ // are unambiguous: the ssh client is gone or nothing is listening.
1064
+ conn._wedgedStreak = verdict === 'wedged' ? (conn._wedgedStreak || 0) + 1 : 0;
1065
+ if (verdict !== 'wedged' || conn._wedgedStreak >= 2) {
1066
+ await this._reapAndRebuild(id, conn, verdict);
1067
+ continue;
1068
+ }
961
1069
  }
962
1070
  }
963
1071
  }
@@ -968,6 +1076,103 @@ export class TunnelManager {
968
1076
  }
969
1077
  }
970
1078
 
1079
+ // Escalating evidence that a tunnel is actually dead, not merely slow:
1080
+ // 'alive' — answered a long-timeout HTTP probe; leave it alone
1081
+ // 'proc-dead' — the ssh client process is gone
1082
+ // 'port-dead' — nothing is listening on the local port
1083
+ // 'remote-down' — the tunnel forwards fine but the REMOTE end refuses:
1084
+ // the probe fails fast with a connection error, not a
1085
+ // timeout. Killing the tunnel won't fix that — the remote
1086
+ // daemon needs starting (e.g. it died during an upgrade).
1087
+ // 'wedged' — port accepts TCP but HTTP hangs to timeout (dead forward)
1088
+ async _confirmDead(conn) {
1089
+ const started = Date.now();
1090
+ let probeErr = null;
1091
+ try {
1092
+ const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
1093
+ signal: AbortSignal.timeout(this.confirmTimeout ?? CONFIRM_TIMEOUT),
1094
+ });
1095
+ if (res.ok) return 'alive';
1096
+ } catch (err) { probeErr = err; }
1097
+
1098
+ if (conn.pid) {
1099
+ try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
1100
+ }
1101
+ if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
1102
+
1103
+ // ssh is alive and its port listens. A hang (timeout) means the forward is
1104
+ // dead; a FAST connection-level error means ssh relayed the remote side's
1105
+ // refusal — the tunnel works, the far daemon doesn't.
1106
+ const failedFast = Date.now() - started < 2000;
1107
+ const timedOut = probeErr && (probeErr.name === 'TimeoutError' || probeErr.name === 'AbortError');
1108
+ if (failedFast && !timedOut) return 'remote-down';
1109
+ return 'wedged';
1110
+ }
1111
+
1112
+ // The tunnel is healthy; the daemon on the far side is what's down. Start it
1113
+ // over ssh rather than pointlessly rebuilding the tunnel. Rate-limited: if
1114
+ // the remote daemon won't stay up, repeated starts won't save it.
1115
+ async _startRemoteDaemon(id, conn) {
1116
+ this._remoteStartAt = this._remoteStartAt || new Map();
1117
+ const last = this._remoteStartAt.get(id) || 0;
1118
+ if (Date.now() - last < REMOTE_START_COOLDOWN_MS) return;
1119
+ this._remoteStartAt.set(id, Date.now());
1120
+
1121
+ console.log(`[Groove:Tunnel] ${id}: tunnel is fine but the remote daemon is down — starting it`);
1122
+ this.daemon.audit.log('tunnel.remote-daemon-start', { id });
1123
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
1124
+ try {
1125
+ await this.autoStart(id);
1126
+ // Confirm it came up; a success resets the failure counters immediately
1127
+ // instead of waiting out another health cycle.
1128
+ for (let i = 0; i < 10; i++) {
1129
+ await new Promise((r) => setTimeout(r, 2000));
1130
+ if (await this._tunnelResponds(conn.localPort)) {
1131
+ conn.failCount = 0;
1132
+ conn.healthy = true;
1133
+ conn._wedgedStreak = 0;
1134
+ console.log(`[Groove:Tunnel] ${id}: remote daemon is back`);
1135
+ this.daemon.broadcast({ type: 'tunnel.health', data: { id, latencyMs: conn.latencyMs, healthy: true } });
1136
+ return;
1137
+ }
1138
+ }
1139
+ console.warn(`[Groove:Tunnel] ${id}: remote daemon did not come back after start`);
1140
+ } catch (err) {
1141
+ console.warn(`[Groove:Tunnel] ${id}: could not start remote daemon: ${err.message}`);
1142
+ }
1143
+ }
1144
+
1145
+ // Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
1146
+ // local port. The remote GUI window points at that port and its WebSocket
1147
+ // retries every 2s, so a same-port rebuild heals an open window without the
1148
+ // user noticing. Only if the rebuild fails does this surface as a disconnect.
1149
+ // Rate-limited so a genuinely dead host degrades to disconnected instead of
1150
+ // thrashing reconnect attempts forever.
1151
+ async _reapAndRebuild(id, conn, reason) {
1152
+ const { localPort } = conn;
1153
+ console.log(`[Groove:Tunnel] Tunnel ${id} confirmed dead (${reason}) — rebuilding`);
1154
+ this.daemon.audit.log('tunnel.reap', { id, reason, failCount: conn.failCount });
1155
+ await this.disconnect(id);
1156
+
1157
+ const lastRebuild = this._rebuildAt?.get(id) || 0;
1158
+ if (Date.now() - lastRebuild < REBUILD_COOLDOWN_MS) {
1159
+ console.log(`[Groove:Tunnel] ${id} already auto-rebuilt recently — leaving disconnected`);
1160
+ return;
1161
+ }
1162
+ this._rebuildAt = this._rebuildAt || new Map();
1163
+ this._rebuildAt.set(id, Date.now());
1164
+
1165
+ try {
1166
+ this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'reconnecting' } });
1167
+ await this.connect(id, { preferredPort: localPort });
1168
+ console.log(`[Groove:Tunnel] ${id} rebuilt on port ${localPort}`);
1169
+ } catch (err) {
1170
+ console.warn(`[Groove:Tunnel] Auto-rebuild of ${id} failed: ${err.message}`);
1171
+ // disconnect() above already broadcast tunnel.disconnected — the GUI is
1172
+ // consistent; the user can reconnect manually when the host is back.
1173
+ }
1174
+ }
1175
+
971
1176
  // Signal 0 only tests for existence — no signal is delivered.
972
1177
  async _waitForExit(pid, timeoutMs) {
973
1178
  const deadline = Date.now() + timeoutMs;
@@ -1004,25 +1209,17 @@ export class TunnelManager {
1004
1209
  throw new Error(`No available local port found (tried ${DEFAULT_LOCAL_PORT}-${DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS - 1})`);
1005
1210
  }
1006
1211
 
1212
+ // Deliberately does NOT kill the ssh processes. They are spawned detached and
1213
+ // are the user's live sessions: killing them on every daemon restart (app
1214
+ // upgrade, promote, crash) is what nuked remote windows mid-session. State is
1215
+ // persisted; the next daemon re-adopts whatever is still alive and serving.
1216
+ // Explicit disconnect()/delete() remain the paths that actually kill a tunnel.
1007
1217
  shutdown() {
1008
1218
  if (this._healthInterval) {
1009
1219
  clearInterval(this._healthInterval);
1010
1220
  this._healthInterval = null;
1011
1221
  }
1012
- for (const [id] of this.active) {
1013
- try {
1014
- const conn = this.active.get(id);
1015
- if (conn?.pid) {
1016
- const cmd = execFileSync('ps', ['-p', String(conn.pid), '-o', 'command='], {
1017
- encoding: 'utf8',
1018
- timeout: 3000,
1019
- }).trim();
1020
- if (cmd.includes('ssh')) {
1021
- process.kill(conn.pid, 'SIGTERM');
1022
- }
1023
- }
1024
- } catch { /* ignore */ }
1025
- }
1222
+ this._saveActive();
1026
1223
  this.active.clear();
1027
1224
  }
1028
1225
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.212",
3
+ "version": "0.27.214",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",