groove-dev 0.27.211 → 0.27.213
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/CLAUDE.md +0 -8
- package/daemon-bridge.js +87 -0
- package/node_modules/@groove-dev/cli/bin/groove.js +20 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/cli/src/client.js +9 -2
- package/node_modules/@groove-dev/cli/src/commands/ask.js +99 -0
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/deliver.js +101 -1
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +25 -7
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +164 -7
- package/node_modules/@groove-dev/daemon/test/reach-hint.test.js +100 -0
- package/node_modules/@groove-dev/daemon/test/tunnel-manager.test.js +253 -0
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/bin/groove.js +20 -0
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/client.js +9 -2
- package/packages/cli/src/commands/ask.js +99 -0
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/deliver.js +101 -1
- package/packages/daemon/src/innerchat-docs.js +25 -7
- package/packages/daemon/src/tunnel-manager.js +164 -7
- package/packages/gui/package.json +1 -1
|
@@ -20,6 +20,12 @@ 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;
|
|
23
29
|
|
|
24
30
|
const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
|
|
25
31
|
|
|
@@ -283,9 +289,21 @@ export class TunnelManager {
|
|
|
283
289
|
const config = this.saved.get(id);
|
|
284
290
|
if (!config) throw new Error(`Remote ${id} not found`);
|
|
285
291
|
|
|
292
|
+
// An existing entry is only reusable if the tunnel actually still carries
|
|
293
|
+
// traffic. After a laptop sleep the SSH client can survive with its forward
|
|
294
|
+
// dead: the local port still ACCEPTS connections but never forwards them, so
|
|
295
|
+
// handing this back returns a port that hangs forever instead of failing —
|
|
296
|
+
// which is what left the remote GUI on a black screen. Probe before reusing,
|
|
297
|
+
// and tear it down if it's a corpse.
|
|
286
298
|
if (this.active.has(id)) {
|
|
287
299
|
const existing = this.active.get(id);
|
|
288
|
-
|
|
300
|
+
if (await this._tunnelResponds(existing.localPort)) {
|
|
301
|
+
return { localPort: existing.localPort, pid: existing.pid, name: config.name };
|
|
302
|
+
}
|
|
303
|
+
console.log(`[Groove:Tunnel] ${config.name}: existing tunnel is not responding — rebuilding`);
|
|
304
|
+
// Reuse the dead tunnel's port so any GUI window pointed at it heals.
|
|
305
|
+
opts = { ...opts, preferredPort: opts.preferredPort || existing.localPort };
|
|
306
|
+
await this.disconnect(id);
|
|
289
307
|
}
|
|
290
308
|
|
|
291
309
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'testing' } });
|
|
@@ -318,7 +336,14 @@ export class TunnelManager {
|
|
|
318
336
|
// Establish SSH tunnel
|
|
319
337
|
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'connecting' } });
|
|
320
338
|
|
|
321
|
-
|
|
339
|
+
// A rebuild wants its old port back: the remote GUI window is pointed at it
|
|
340
|
+
// and will self-heal over WebSocket retry only if the port stays the same.
|
|
341
|
+
let localPort;
|
|
342
|
+
if (opts.preferredPort && !(await this._isPortInUse(opts.preferredPort))) {
|
|
343
|
+
localPort = opts.preferredPort;
|
|
344
|
+
} else {
|
|
345
|
+
localPort = await this._findAvailablePort();
|
|
346
|
+
}
|
|
322
347
|
const target = `${config.user}@${config.host}`;
|
|
323
348
|
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
324
349
|
|
|
@@ -432,11 +457,23 @@ export class TunnelManager {
|
|
|
432
457
|
return { localPort, pid: tunnel.pid, name: config.name, url };
|
|
433
458
|
}
|
|
434
459
|
|
|
460
|
+
// Does the tunnel actually serve a request, as opposed to merely holding an
|
|
461
|
+
// open listening socket? A wedged forward passes a TCP connect test but never
|
|
462
|
+
// answers, so only an HTTP round-trip proves it.
|
|
463
|
+
async _tunnelResponds(localPort, timeoutMs = this.healthTimeout ?? HEALTH_TIMEOUT) {
|
|
464
|
+
try {
|
|
465
|
+
const res = await fetch(`http://localhost:${localPort}/api/health`, {
|
|
466
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
467
|
+
});
|
|
468
|
+
return res.ok;
|
|
469
|
+
} catch { return false; }
|
|
470
|
+
}
|
|
471
|
+
|
|
435
472
|
async disconnect(id) {
|
|
436
473
|
const conn = this.active.get(id);
|
|
437
474
|
if (!conn) return;
|
|
438
475
|
|
|
439
|
-
const { pid } = conn;
|
|
476
|
+
const { pid, localPort } = conn;
|
|
440
477
|
try {
|
|
441
478
|
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
442
479
|
encoding: 'utf8',
|
|
@@ -444,9 +481,20 @@ export class TunnelManager {
|
|
|
444
481
|
}).trim();
|
|
445
482
|
if (cmd.includes('ssh')) {
|
|
446
483
|
process.kill(pid, 'SIGTERM');
|
|
484
|
+
// An SSH client stuck on a dead TCP session can sit on SIGTERM long
|
|
485
|
+
// enough that the next connect() finds the port still bound. Give it a
|
|
486
|
+
// moment, then stop asking politely — otherwise the leftover listener
|
|
487
|
+
// keeps answering (and hanging) on the port we're about to reuse.
|
|
488
|
+
const gone = await this._waitForExit(pid, 3000);
|
|
489
|
+
if (!gone) {
|
|
490
|
+
try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
|
|
491
|
+
await this._waitForExit(pid, 2000);
|
|
492
|
+
}
|
|
447
493
|
}
|
|
448
494
|
} catch { /* process already dead */ }
|
|
449
495
|
|
|
496
|
+
if (localPort) await this._waitForPortFree(localPort, 3000);
|
|
497
|
+
|
|
450
498
|
this.active.delete(id);
|
|
451
499
|
|
|
452
500
|
const config = this.saved.get(id);
|
|
@@ -873,11 +921,35 @@ export class TunnelManager {
|
|
|
873
921
|
}
|
|
874
922
|
|
|
875
923
|
async _healthCheckAll() {
|
|
924
|
+
// Reaping now awaits process death, which can outlast the interval — don't
|
|
925
|
+
// let a second pass start on top of one already tearing a tunnel down.
|
|
926
|
+
if (this._healthRunning) return;
|
|
927
|
+
this._healthRunning = true;
|
|
928
|
+
try { await this._healthCheckPass(); } finally { this._healthRunning = false; }
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async _healthCheckPass() {
|
|
932
|
+
// Timers don't fire while the machine is asleep, so an interval that should
|
|
933
|
+
// have run every HEALTH_INTERVAL arriving far later means we PROBABLY just
|
|
934
|
+
// woke up — but not certainly: this daemon also blocks its event loop for
|
|
935
|
+
// long stretches (execFileSync ssh calls in test/upgrade paths), which
|
|
936
|
+
// produces the same gap on a machine that never slept. So a gap only makes
|
|
937
|
+
// tunnels *suspect* — it fast-tracks them to the confirmation ladder below.
|
|
938
|
+
// It must never lower the bar for killing one (that misdiagnosis dropped a
|
|
939
|
+
// healthy DGX tunnel twice in ten minutes).
|
|
940
|
+
const now = Date.now();
|
|
941
|
+
const gap = now - (this._lastHealthCheck || now);
|
|
942
|
+
this._lastHealthCheck = now;
|
|
943
|
+
const suspectAll = gap > HEALTH_INTERVAL * 3;
|
|
944
|
+
if (suspectAll && this.active.size > 0) {
|
|
945
|
+
console.log(`[Groove:Tunnel] ${Math.round(gap / 1000)}s timer gap (sleep or blocked loop) — verifying tunnels`);
|
|
946
|
+
}
|
|
947
|
+
|
|
876
948
|
for (const [id, conn] of this.active) {
|
|
877
949
|
try {
|
|
878
950
|
const start = Date.now();
|
|
879
951
|
const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
|
|
880
|
-
signal: AbortSignal.timeout(HEALTH_TIMEOUT),
|
|
952
|
+
signal: AbortSignal.timeout(this.healthTimeout ?? HEALTH_TIMEOUT),
|
|
881
953
|
});
|
|
882
954
|
if (res.ok) {
|
|
883
955
|
conn.latencyMs = Date.now() - start;
|
|
@@ -889,9 +961,30 @@ export class TunnelManager {
|
|
|
889
961
|
}
|
|
890
962
|
} catch {
|
|
891
963
|
conn.failCount = (conn.failCount || 0) + 1;
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
964
|
+
// A failed 5s probe is WEAK evidence: it can't distinguish a dead
|
|
965
|
+
// tunnel from a remote daemon that's briefly busy or our own blocked
|
|
966
|
+
// event loop. Never kill on it. Once failures accumulate (or a timer
|
|
967
|
+
// gap makes everything suspect), run the confirmation ladder, which
|
|
968
|
+
// can — a healthy verdict there resets the count.
|
|
969
|
+
if (conn.failCount >= MAX_FAIL_COUNT || suspectAll) {
|
|
970
|
+
const verdict = await this._confirmDead(conn);
|
|
971
|
+
if (verdict === 'alive') {
|
|
972
|
+
conn.failCount = 0;
|
|
973
|
+
conn.healthy = true;
|
|
974
|
+
conn._wedgedStreak = 0;
|
|
975
|
+
} else {
|
|
976
|
+
conn.healthy = false;
|
|
977
|
+
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
978
|
+
// 'wedged' (port accepts, HTTP silent even at long timeout) is the
|
|
979
|
+
// one verdict with a false-positive path — a remote event loop
|
|
980
|
+
// blocked 15s+ — so demand it twice in a row. proc-dead/port-dead
|
|
981
|
+
// are unambiguous: the ssh client is gone or nothing is listening.
|
|
982
|
+
conn._wedgedStreak = verdict === 'wedged' ? (conn._wedgedStreak || 0) + 1 : 0;
|
|
983
|
+
if (verdict !== 'wedged' || conn._wedgedStreak >= 2) {
|
|
984
|
+
await this._reapAndRebuild(id, conn, verdict);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
895
988
|
}
|
|
896
989
|
}
|
|
897
990
|
this.daemon.broadcast({
|
|
@@ -901,6 +994,70 @@ export class TunnelManager {
|
|
|
901
994
|
}
|
|
902
995
|
}
|
|
903
996
|
|
|
997
|
+
// Escalating evidence that a tunnel is actually dead, not merely slow:
|
|
998
|
+
// 'alive' — answered a long-timeout HTTP probe; leave it alone
|
|
999
|
+
// 'proc-dead' — the ssh client process is gone
|
|
1000
|
+
// 'port-dead' — nothing is listening on the local port
|
|
1001
|
+
// 'wedged' — port accepts TCP but HTTP never answers (dead forward)
|
|
1002
|
+
async _confirmDead(conn) {
|
|
1003
|
+
if (await this._tunnelResponds(conn.localPort, this.confirmTimeout ?? CONFIRM_TIMEOUT)) return 'alive';
|
|
1004
|
+
if (conn.pid) {
|
|
1005
|
+
try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
|
|
1006
|
+
}
|
|
1007
|
+
if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
|
|
1008
|
+
return 'wedged';
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
|
|
1012
|
+
// local port. The remote GUI window points at that port and its WebSocket
|
|
1013
|
+
// retries every 2s, so a same-port rebuild heals an open window without the
|
|
1014
|
+
// user noticing. Only if the rebuild fails does this surface as a disconnect.
|
|
1015
|
+
// Rate-limited so a genuinely dead host degrades to disconnected instead of
|
|
1016
|
+
// thrashing reconnect attempts forever.
|
|
1017
|
+
async _reapAndRebuild(id, conn, reason) {
|
|
1018
|
+
const { localPort } = conn;
|
|
1019
|
+
console.log(`[Groove:Tunnel] Tunnel ${id} confirmed dead (${reason}) — rebuilding`);
|
|
1020
|
+
this.daemon.audit.log('tunnel.reap', { id, reason, failCount: conn.failCount });
|
|
1021
|
+
await this.disconnect(id);
|
|
1022
|
+
|
|
1023
|
+
const lastRebuild = this._rebuildAt?.get(id) || 0;
|
|
1024
|
+
if (Date.now() - lastRebuild < REBUILD_COOLDOWN_MS) {
|
|
1025
|
+
console.log(`[Groove:Tunnel] ${id} already auto-rebuilt recently — leaving disconnected`);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
this._rebuildAt = this._rebuildAt || new Map();
|
|
1029
|
+
this._rebuildAt.set(id, Date.now());
|
|
1030
|
+
|
|
1031
|
+
try {
|
|
1032
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'reconnecting' } });
|
|
1033
|
+
await this.connect(id, { preferredPort: localPort });
|
|
1034
|
+
console.log(`[Groove:Tunnel] ${id} rebuilt on port ${localPort}`);
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
console.warn(`[Groove:Tunnel] Auto-rebuild of ${id} failed: ${err.message}`);
|
|
1037
|
+
// disconnect() above already broadcast tunnel.disconnected — the GUI is
|
|
1038
|
+
// consistent; the user can reconnect manually when the host is back.
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Signal 0 only tests for existence — no signal is delivered.
|
|
1043
|
+
async _waitForExit(pid, timeoutMs) {
|
|
1044
|
+
const deadline = Date.now() + timeoutMs;
|
|
1045
|
+
while (Date.now() < deadline) {
|
|
1046
|
+
try { process.kill(pid, 0); } catch { return true; } // gone
|
|
1047
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
1048
|
+
}
|
|
1049
|
+
try { process.kill(pid, 0); return false; } catch { return true; }
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
async _waitForPortFree(port, timeoutMs) {
|
|
1053
|
+
const deadline = Date.now() + timeoutMs;
|
|
1054
|
+
while (Date.now() < deadline) {
|
|
1055
|
+
if (!(await this._isPortInUse(port))) return true;
|
|
1056
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1057
|
+
}
|
|
1058
|
+
return !(await this._isPortInUse(port));
|
|
1059
|
+
}
|
|
1060
|
+
|
|
904
1061
|
_isPortInUse(port) {
|
|
905
1062
|
return new Promise((resolve) => {
|
|
906
1063
|
const conn = createConnection({ host: '127.0.0.1', port });
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// GROOVE — Agent reach hint (InnerChat discoverability)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Spawn-prompt capabilities decay out of a long session, after which agents
|
|
5
|
+
// deny they can contact anyone. These pin the per-turn hint that replaces it.
|
|
6
|
+
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { agentReachHint } from '../src/deliver.js';
|
|
10
|
+
|
|
11
|
+
function daemonWith(agents, peers = []) {
|
|
12
|
+
return {
|
|
13
|
+
registry: { getAll: () => agents },
|
|
14
|
+
config: { innerchatPeers: peers },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const me = { name: 'fullstack-3', role: 'fullstack' };
|
|
19
|
+
const roster = [me, { name: 'Integration-Manager', role: 'fullstack' }, { name: 'Axom-UX', role: 'frontend' }];
|
|
20
|
+
|
|
21
|
+
describe('agentReachHint', () => {
|
|
22
|
+
it('always names the CLI verbs, even on an unrelated turn', () => {
|
|
23
|
+
const hint = agentReachHint(daemonWith(roster), me, 'refactor the token parser');
|
|
24
|
+
assert.match(hint, /groove ask/);
|
|
25
|
+
assert.match(hint, /groove who/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rules out the built-in sub-agent tools that cannot reach GROOVE agents', () => {
|
|
29
|
+
const hint = agentReachHint(daemonWith(roster), me, 'anything');
|
|
30
|
+
assert.match(hint, /CANNOT reach them/i);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('expands to a ready-to-run command naming the agent the user meant', () => {
|
|
34
|
+
const hint = agentReachHint(daemonWith(roster), me, 'ask Integration-Manager about the relay shape');
|
|
35
|
+
assert.match(hint, /groove ask Integration-Manager "your question here"/);
|
|
36
|
+
assert.doesNotMatch(hint, /<name>/, 'should not leave a placeholder to fill in');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('falls back to the roster when intent is clear but the target is not', () => {
|
|
40
|
+
const hint = agentReachHint(daemonWith(roster), me, 'coordinate with the other agents on this');
|
|
41
|
+
assert.match(hint, /groove who/);
|
|
42
|
+
assert.match(hint, /Integration-Manager, Axom-UX/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not resolve a target from an ambiguous partial mention', () => {
|
|
46
|
+
const two = [me, { name: 'fullstack-1', role: 'x' }, { name: 'fullstack-2', role: 'x' }];
|
|
47
|
+
const hint = agentReachHint(daemonWith(two), me, 'ask fullstack about it');
|
|
48
|
+
assert.doesNotMatch(hint, /groove ask fullstack-1 "/);
|
|
49
|
+
assert.match(hint, /groove who/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('never suggests messaging yourself', () => {
|
|
53
|
+
const hint = agentReachHint(daemonWith(roster), me, 'ask fullstack-3 what it thinks');
|
|
54
|
+
assert.doesNotMatch(hint, /groove ask fullstack-3 "/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('mentions peer machines and the name@peer form when peers exist', () => {
|
|
58
|
+
const hint = agentReachHint(daemonWith(roster, [{ alias: 'spark' }]), me, 'ask someone about it');
|
|
59
|
+
assert.match(hint, /name@peer/);
|
|
60
|
+
assert.match(hint, /spark/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('stays silent when there is genuinely nobody to talk to', () => {
|
|
64
|
+
assert.equal(agentReachHint(daemonWith([me]), me, 'ask someone'), null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Naming the feature is an explicit request — the tier that must never rely
|
|
68
|
+
// on heuristics, because it is typically typed AFTER the agent has already
|
|
69
|
+
// claimed the capability does not exist.
|
|
70
|
+
it('re-sends the full reference when the user names InnerChat', () => {
|
|
71
|
+
for (const phrase of ['use innerChat', 'inner chat', 'inner-chat', 'InnerChat please', 'run groove ask']) {
|
|
72
|
+
const hint = agentReachHint({ port: 31415, ...daemonWith(roster) }, me, `${phrase} with the team`);
|
|
73
|
+
assert.match(hint, /Full reference/, `"${phrase}" should trigger the full block`);
|
|
74
|
+
assert.match(hint, /groove ask/);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('tells the agent the feature is real when explicitly asked for it', () => {
|
|
79
|
+
const hint = agentReachHint({ port: 31415, ...daemonWith(roster) }, me, 'use innerchat');
|
|
80
|
+
assert.match(hint, /It exists, it is wired up/);
|
|
81
|
+
assert.match(hint, /Do not tell the user the feature is/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('still resolves the named target alongside the full reference', () => {
|
|
85
|
+
const hint = agentReachHint({ port: 31415, ...daemonWith(roster) }, me, 'use innerchat with Axom-UX');
|
|
86
|
+
assert.match(hint, /groove ask Axom-UX "your question here"/);
|
|
87
|
+
assert.match(hint, /Full reference/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('does not pay for the full reference on an ordinary turn', () => {
|
|
91
|
+
const hint = agentReachHint(daemonWith(roster), me, 'refactor the parser');
|
|
92
|
+
assert.doesNotMatch(hint, /Full reference/);
|
|
93
|
+
assert.ok(hint.length < 400, 'the always-on tier must stay cheap');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('survives a broken registry rather than blocking delivery', () => {
|
|
97
|
+
const broken = { registry: { getAll() { throw new Error('boom'); } } };
|
|
98
|
+
assert.equal(agentReachHint(broken, me, 'ask someone'), null);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// GROOVE — TunnelManager Tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Regression coverage for wake-from-sleep recovery. The failure these guard
|
|
5
|
+
// against: an SSH tunnel cut by a laptop sleep leaves a local listener that
|
|
6
|
+
// still ACCEPTS TCP connections but never forwards them. A TCP-connect probe
|
|
7
|
+
// calls that healthy, so the dead tunnel was handed out forever and the remote
|
|
8
|
+
// GUI loaded a port that hung instead of failing — a black window that only a
|
|
9
|
+
// full app restart cleared.
|
|
10
|
+
|
|
11
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
14
|
+
import { tmpdir } from 'os';
|
|
15
|
+
import { resolve } from 'path';
|
|
16
|
+
import { createServer } from 'net';
|
|
17
|
+
import { TunnelManager } from '../src/tunnel-manager.js';
|
|
18
|
+
|
|
19
|
+
function makeDaemon(grooveDir) {
|
|
20
|
+
const broadcasts = [];
|
|
21
|
+
return {
|
|
22
|
+
broadcasts,
|
|
23
|
+
grooveDir,
|
|
24
|
+
projectDir: process.cwd(),
|
|
25
|
+
audit: { log() {} },
|
|
26
|
+
broadcast(m) { broadcasts.push(m); },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A tunnel wedged by sleep: the socket is accepted and then ignored forever.
|
|
31
|
+
function startWedgedListener() {
|
|
32
|
+
const sockets = [];
|
|
33
|
+
const server = createServer((sock) => { sockets.push(sock); });
|
|
34
|
+
return new Promise((res) => {
|
|
35
|
+
server.listen(0, '127.0.0.1', () => {
|
|
36
|
+
res({
|
|
37
|
+
port: server.address().port,
|
|
38
|
+
close: () => { for (const s of sockets) s.destroy(); server.close(); },
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A tunnel that works — answers /api/health like the remote daemon would.
|
|
45
|
+
function startHealthyListener() {
|
|
46
|
+
const server = createServer((sock) => {
|
|
47
|
+
sock.on('data', () => {
|
|
48
|
+
const body = '{"ok":true}';
|
|
49
|
+
sock.end(
|
|
50
|
+
'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n'
|
|
51
|
+
+ `Content-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
return new Promise((res) => {
|
|
56
|
+
server.listen(0, '127.0.0.1', () => {
|
|
57
|
+
res({ port: server.address().port, close: () => server.close() });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('TunnelManager — wake-from-sleep recovery', () => {
|
|
63
|
+
let daemon, mgr, grooveDir;
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
grooveDir = mkdtempSync(resolve(tmpdir(), 'groove-tunnel-'));
|
|
67
|
+
daemon = makeDaemon(grooveDir);
|
|
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;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
mgr.shutdown();
|
|
77
|
+
try { rmSync(grooveDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('a TCP-accept probe cannot tell a wedged tunnel from a live one', async () => {
|
|
81
|
+
const wedged = await startWedgedListener();
|
|
82
|
+
try {
|
|
83
|
+
// This is what the old code trusted — and why the bug survived.
|
|
84
|
+
assert.equal(await mgr._isPortInUse(wedged.port), true);
|
|
85
|
+
} finally { wedged.close(); }
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('_tunnelResponds rejects a tunnel that accepts but never answers', async () => {
|
|
89
|
+
const wedged = await startWedgedListener();
|
|
90
|
+
try {
|
|
91
|
+
assert.equal(await mgr._tunnelResponds(wedged.port, 800), false);
|
|
92
|
+
} finally { wedged.close(); }
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('_tunnelResponds accepts a tunnel that actually serves', async () => {
|
|
96
|
+
const live = await startHealthyListener();
|
|
97
|
+
try {
|
|
98
|
+
assert.equal(await mgr._tunnelResponds(live.port, 3000), true);
|
|
99
|
+
} finally { live.close(); }
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('connect() rebuilds instead of handing back a wedged tunnel', async () => {
|
|
103
|
+
const wedged = await startWedgedListener();
|
|
104
|
+
try {
|
|
105
|
+
mgr.saved.set('s19', {
|
|
106
|
+
id: 's19', name: 'S19 Agency', host: 'example.invalid',
|
|
107
|
+
user: 'ops', port: 22, lastConnected: new Date().toISOString(),
|
|
108
|
+
});
|
|
109
|
+
mgr.active.set('s19', {
|
|
110
|
+
pid: 999999, localPort: wedged.port, healthy: true, failCount: 0,
|
|
111
|
+
startedAt: new Date().toISOString(),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Host is unreachable, so the rebuild fails — the point is that it TRIED
|
|
115
|
+
// rather than returning the dead port as if it were usable.
|
|
116
|
+
await assert.rejects(
|
|
117
|
+
() => mgr.connect('s19', { skipTest: false }),
|
|
118
|
+
(err) => !/^$/.test(err.message),
|
|
119
|
+
);
|
|
120
|
+
assert.equal(mgr.active.has('s19'), false, 'the dead tunnel was torn down');
|
|
121
|
+
} finally { wedged.close(); }
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('connect() reuses a tunnel that is genuinely alive', async () => {
|
|
125
|
+
const live = await startHealthyListener();
|
|
126
|
+
try {
|
|
127
|
+
mgr.saved.set('spark', { id: 'spark', name: 'DGX Spark', host: '10.0.0.5', user: 'rok', port: 22 });
|
|
128
|
+
mgr.active.set('spark', {
|
|
129
|
+
pid: 12345, localPort: live.port, healthy: true, failCount: 0,
|
|
130
|
+
startedAt: new Date().toISOString(),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const res = await mgr.connect('spark');
|
|
134
|
+
assert.equal(res.localPort, live.port, 'a working tunnel is reused, not rebuilt');
|
|
135
|
+
assert.equal(mgr.active.has('spark'), true);
|
|
136
|
+
} finally { live.close(); }
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('the health pass reaps a proc-dead tunnel and tries to rebuild it', async () => {
|
|
140
|
+
const wedged = await startWedgedListener();
|
|
141
|
+
try {
|
|
142
|
+
mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: 'example.invalid', user: 'ops', port: 22 });
|
|
143
|
+
mgr.active.set('s19', {
|
|
144
|
+
pid: 999999, // no such process → verdict 'proc-dead', reap on first confirm
|
|
145
|
+
localPort: wedged.port, healthy: true,
|
|
146
|
+
failCount: 99, // already past the failure limit
|
|
147
|
+
startedAt: new Date().toISOString(),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const rebuilds = [];
|
|
151
|
+
mgr.connect = async (id, opts) => { rebuilds.push({ id, opts }); throw new Error('host unreachable'); };
|
|
152
|
+
|
|
153
|
+
await mgr._healthCheckAll();
|
|
154
|
+
|
|
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');
|
|
158
|
+
assert.ok(
|
|
159
|
+
daemon.broadcasts.some((b) => b.type === 'tunnel.unhealthy'),
|
|
160
|
+
'the GUI is told the tunnel went unhealthy',
|
|
161
|
+
);
|
|
162
|
+
} finally { wedged.close(); }
|
|
163
|
+
});
|
|
164
|
+
|
|
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 () => {
|
|
203
|
+
const wedged = await startWedgedListener();
|
|
204
|
+
try {
|
|
205
|
+
mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: 'example.invalid', user: 'ops', port: 22 });
|
|
206
|
+
mgr.active.set('s19', {
|
|
207
|
+
pid: process.pid, // ssh "alive" → verdict is 'wedged', not 'proc-dead'
|
|
208
|
+
localPort: wedged.port, healthy: true, failCount: 99,
|
|
209
|
+
startedAt: new Date().toISOString(),
|
|
210
|
+
});
|
|
211
|
+
mgr.connect = async () => { throw new Error('unreachable'); };
|
|
212
|
+
|
|
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);
|
|
216
|
+
|
|
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');
|
|
220
|
+
} finally { wedged.close(); }
|
|
221
|
+
});
|
|
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('_waitForPortFree reports a released port', async () => {
|
|
244
|
+
const live = await startHealthyListener();
|
|
245
|
+
assert.equal(await mgr._waitForPortFree(live.port, 600), false, 'still held while listening');
|
|
246
|
+
live.close();
|
|
247
|
+
assert.equal(await mgr._waitForPortFree(live.port, 3000), true, 'free once closed');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('_waitForExit returns true for a pid that does not exist', async () => {
|
|
251
|
+
assert.equal(await mgr._waitForExit(999999, 500), true);
|
|
252
|
+
});
|
|
253
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.213",
|
|
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)",
|
|
@@ -21,6 +21,7 @@ import { disconnect } from '../src/commands/disconnect.js';
|
|
|
21
21
|
import { remotes } from '../src/commands/remotes.js';
|
|
22
22
|
import { audit } from '../src/commands/audit.js';
|
|
23
23
|
import { federationPair, federationUnpair, federationList, federationStatus } from '../src/commands/federation.js';
|
|
24
|
+
import { ask, tell, who } from '../src/commands/ask.js';
|
|
24
25
|
import { createRequire } from 'node:module';
|
|
25
26
|
const require = createRequire(import.meta.url);
|
|
26
27
|
const { version } = require('../../../package.json');
|
|
@@ -74,6 +75,25 @@ program
|
|
|
74
75
|
.option('-f, --force', 'Required when agents are still running')
|
|
75
76
|
.action(nuke);
|
|
76
77
|
|
|
78
|
+
// InnerChat — agent-to-agent messaging. Listed high in --help because an
|
|
79
|
+
// agent that has lost the capability from context rediscovers it here.
|
|
80
|
+
program
|
|
81
|
+
.command('ask <agent> <message>')
|
|
82
|
+
.description('Ask another agent a question and wait for their answer')
|
|
83
|
+
.option('--from <name>', 'your agent name (defaults to $GROOVE_AGENT_NAME)')
|
|
84
|
+
.action(ask);
|
|
85
|
+
|
|
86
|
+
program
|
|
87
|
+
.command('tell <agent> <message>')
|
|
88
|
+
.description('Send another agent a message without waiting for a reply')
|
|
89
|
+
.option('--from <name>', 'your agent name (defaults to $GROOVE_AGENT_NAME)')
|
|
90
|
+
.action(tell);
|
|
91
|
+
|
|
92
|
+
program
|
|
93
|
+
.command('who')
|
|
94
|
+
.description('List agents you can message with `groove ask` / `groove tell`')
|
|
95
|
+
.action(who);
|
|
96
|
+
|
|
77
97
|
program
|
|
78
98
|
.command('rotate <id>')
|
|
79
99
|
.description('Rotate an agent (kill + respawn with fresh context)')
|
|
@@ -33,8 +33,15 @@ export async function apiCall(method, path, body) {
|
|
|
33
33
|
const res = await fetch(url, options);
|
|
34
34
|
|
|
35
35
|
if (!res.ok) {
|
|
36
|
-
const
|
|
37
|
-
|
|
36
|
+
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
37
|
+
const err = new Error(body.error || `HTTP ${res.status}`);
|
|
38
|
+
// Carry the response body onto the error. The daemon's actionable fields
|
|
39
|
+
// (availableAgents, didYouMean, note) are the whole point of its error
|
|
40
|
+
// messages — dropping them leaves the caller with a dead end.
|
|
41
|
+
err.status = res.status;
|
|
42
|
+
err.body = body;
|
|
43
|
+
Object.assign(err, body);
|
|
44
|
+
throw err;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
return res.json();
|