groove-dev 0.27.186 → 0.27.188

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/innerchat/Screenshot_2026-07-23_at_1.30.16_PM.png +0 -0
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/index.js +6 -0
  5. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +34 -13
  6. package/node_modules/@groove-dev/daemon/src/introducer.js +2 -2
  7. package/node_modules/@groove-dev/daemon/src/process.js +13 -1
  8. package/node_modules/@groove-dev/daemon/src/teams.js +36 -4
  9. package/node_modules/@groove-dev/daemon/src/watcher.js +230 -104
  10. package/node_modules/@groove-dev/daemon/test/teams.test.js +48 -1
  11. package/node_modules/@groove-dev/daemon/test/watcher.test.js +68 -10
  12. package/node_modules/@groove-dev/gui/dist/assets/index-Cat5pJUx.css +1 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/{index-DOOaCFRS.js → index-fyGUwOq4.js} +222 -222
  14. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  15. package/node_modules/@groove-dev/gui/package.json +1 -1
  16. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +31 -3
  17. package/node_modules/@groove-dev/gui/src/components/agents/agent-panel.jsx +106 -10
  18. package/node_modules/@groove-dev/gui/src/lib/logpaths.js +1 -1
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +6 -6
  20. package/node_modules/@groove-dev/gui/src/stores/helpers.js +28 -0
  21. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +20 -9
  22. package/package.json +1 -1
  23. package/packages/cli/package.json +1 -1
  24. package/packages/daemon/package.json +1 -1
  25. package/packages/daemon/src/index.js +6 -0
  26. package/packages/daemon/src/innerchat-docs.js +34 -13
  27. package/packages/daemon/src/introducer.js +2 -2
  28. package/packages/daemon/src/process.js +13 -1
  29. package/packages/daemon/src/teams.js +36 -4
  30. package/packages/daemon/src/watcher.js +230 -104
  31. package/packages/gui/dist/assets/index-Cat5pJUx.css +1 -0
  32. package/packages/gui/dist/assets/{index-DOOaCFRS.js → index-fyGUwOq4.js} +222 -222
  33. package/packages/gui/dist/index.html +2 -2
  34. package/packages/gui/package.json +1 -1
  35. package/packages/gui/src/components/agents/agent-feed.jsx +31 -3
  36. package/packages/gui/src/components/agents/agent-panel.jsx +106 -10
  37. package/packages/gui/src/lib/logpaths.js +1 -1
  38. package/packages/gui/src/stores/groove.js +6 -6
  39. package/packages/gui/src/stores/helpers.js +28 -0
  40. package/packages/gui/src/stores/slices/agents-slice.js +20 -9
  41. package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +0 -1
  42. package/packages/gui/dist/assets/index-CU8L_r5f.css +0 -1
@@ -2,36 +2,52 @@
2
2
 
3
3
  import { spawn } from 'child_process';
4
4
  import { randomUUID } from 'crypto';
5
+ import {
6
+ mkdirSync, writeFileSync, readFileSync, existsSync, rmSync,
7
+ openSync, readSync, fstatSync, closeSync,
8
+ } from 'fs';
9
+ import { resolve } from 'path';
5
10
  import { deliverInstruction } from './deliver.js';
6
11
 
7
12
  // Watches are bounded so a wedged process or a condition that never comes true
8
13
  // can't poll forever or hold a slot indefinitely.
9
14
  const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
10
- const MAX_TIMEOUT_MS = 6 * 60 * 60 * 1000;
15
+ const MAX_TIMEOUT_MS = 24 * 60 * 60 * 1000; // training can run for hours
11
16
  const DEFAULT_POLL_MS = 15 * 1000;
12
17
  const MIN_POLL_MS = 3 * 1000;
18
+ // Command mode just stats a sentinel file — nearly free, so poll it fast for a
19
+ // responsive wake. `until` mode spawns a shell each tick, hence the slower floor.
20
+ const COMMAND_POLL_MS = 500;
13
21
  const MAX_WATCHES_PER_AGENT = 5;
14
22
  const OUTPUT_TAIL = 4000;
23
+ const HISTORY_TTL_MS = 24 * 60 * 60 * 1000; // prune finished watches after a day
15
24
 
16
25
  /**
17
- * Wake-on-completion for agents.
26
+ * Wake-on-completion for agents — durable across daemon restarts.
18
27
  *
19
- * An agent promises to "report back when the tests finish", but its process
20
- * ends when the turn does so nothing is left to report. A Watch lives in the
21
- * daemon, not the agent's session: it outlives the turn, and when its target
22
- * finishes it resumes the agent (from `completed` if need be, via the same
23
- * pipe as user chat) with the outcome.
28
+ * An agent promises to "report back when the training finishes", but its
29
+ * process ends when the turn does, so nothing is left to report. A Watch lives
30
+ * in the daemon, not the agent's session: it outlives the turn, and when its
31
+ * target finishes it resumes the agent (from `completed` if need be, via the
32
+ * same pipe as user chat) with the outcome.
24
33
  *
25
- * Two flavours:
26
- * - command: the daemon runs a command detached and owns its lifecycle, so
27
- * it has the real exit code and output when it finishes.
28
- * - until: the daemon polls a check command for something already running,
29
- * and wakes the agent when the check first succeeds (exit 0).
34
+ * Crucially, watches survive a daemon restart. They are persisted to disk, and:
35
+ * - command: the daemon launches the command DETACHED, redirecting output to
36
+ * a file and writing its exit code to a sentinel file when it finishes. The
37
+ * job keeps running if the daemon dies; on restart the watcher re-polls the
38
+ * sentinel and fires when it appears. This is what makes "launch a training,
39
+ * get notified when done" reliable even if the harness restarts underneath.
40
+ * - until: the daemon polls a check command for something already running and
41
+ * wakes the agent when the check first succeeds (exit 0). Re-polled on
42
+ * restart the same way.
30
43
  */
31
44
  export class Watcher {
32
45
  constructor(daemon) {
33
46
  this.daemon = daemon;
34
47
  this.watches = new Map();
48
+ this.runsDir = resolve(daemon.grooveDir, 'watch-runs');
49
+ this.persistPath = resolve(daemon.grooveDir, 'watches.json');
50
+ try { mkdirSync(this.runsDir, { recursive: true }); } catch { /* exists */ }
35
51
  }
36
52
 
37
53
  create(agentId, opts = {}) {
@@ -61,84 +77,109 @@ export class Watcher {
61
77
  timeoutMs,
62
78
  status: 'active',
63
79
  createdAt: Date.now(),
64
- _child: null,
65
- _poll: null,
66
- _deadline: null,
80
+ // populated for command mode:
81
+ pid: null, outFile: null, statusFile: null,
82
+ // runtime-only (never persisted):
83
+ _poll: null, _deadline: null,
67
84
  };
68
85
 
69
86
  this.watches.set(watch.id, watch);
70
- watch._deadline = setTimeout(() => this._fireTimeout(watch), timeoutMs);
71
- if (watch.mode === 'command') this._runCommand(watch);
72
- else this._startPolling(watch);
87
+ if (watch.mode === 'command') this._launchDetached(watch);
88
+ this._arm(watch);
89
+ this._persist();
73
90
 
74
91
  this.daemon.audit?.log('watch.create', { id: watch.id, agent: agentId, mode: watch.mode });
75
92
  this.daemon.broadcast?.({ type: 'watch:created', data: this._public(watch) });
76
93
  return this._public(watch);
77
94
  }
78
95
 
79
- // ── command mode: daemon owns the process ─────────────────────
80
-
81
- _runCommand(watch) {
82
- const child = spawn('/bin/sh', ['-c', watch.command], {
83
- cwd: watch.cwd,
84
- env: process.env,
85
- detached: false,
86
- });
87
- watch._child = child;
96
+ // Start the timers for an active watch (used on both create and restore).
97
+ _arm(watch) {
98
+ // On restore the deadline may already be in the past — fire promptly rather
99
+ // than negative. The floor only guards against a 0/negative timer.
100
+ const remaining = Math.max(50, watch.timeoutMs - (Date.now() - watch.createdAt));
101
+ watch._deadline = setTimeout(() => this._fireTimeout(watch), remaining);
102
+ const interval = watch.mode === 'command' ? COMMAND_POLL_MS : watch.pollMs;
103
+ watch._poll = setInterval(() => this._tick(watch), interval);
104
+ // Check immediately — the job may have finished while the daemon was down,
105
+ // or the `until` condition may already hold.
106
+ this._tick(watch);
107
+ }
88
108
 
89
- const chunks = [];
90
- let bytes = 0;
91
- const collect = (buf) => {
92
- // Keep only the tail — a chatty build shouldn't grow this unbounded.
93
- chunks.push(buf);
94
- bytes += buf.length;
95
- while (bytes > OUTPUT_TAIL * 2 && chunks.length > 1) bytes -= chunks.shift().length;
96
- };
97
- child.stdout?.on('data', collect);
98
- child.stderr?.on('data', collect);
109
+ // ── command mode: detached job + exit sentinel ────────────────
110
+
111
+ _launchDetached(watch) {
112
+ const dir = resolve(this.runsDir, watch.id);
113
+ mkdirSync(dir, { recursive: true });
114
+ watch.outFile = resolve(dir, 'out.log');
115
+ watch.statusFile = resolve(dir, 'exit');
116
+ const scriptFile = resolve(dir, 'run.sh');
117
+
118
+ // Run the command in a SUBSHELL so its own `exit N` can't kill the wrapper
119
+ // before the sentinel is written; tee output to a file, then atomically
120
+ // publish the exit code so the poller never sees a half-written value.
121
+ const script = [
122
+ '#!/bin/sh',
123
+ '(',
124
+ watch.command,
125
+ `) > ${shq(watch.outFile)} 2>&1`,
126
+ `echo $? > ${shq(watch.statusFile)}.tmp && mv ${shq(watch.statusFile)}.tmp ${shq(watch.statusFile)}`,
127
+ '',
128
+ ].join('\n');
129
+ writeFileSync(scriptFile, script, { mode: 0o700 });
99
130
 
100
- child.on('error', (err) => {
101
- this._wake(watch, {
102
- outcome: 'error',
103
- summary: `Could not start the command: ${err.message}`,
131
+ try {
132
+ const child = spawn('/bin/sh', [scriptFile], {
133
+ cwd: watch.cwd,
134
+ env: process.env,
135
+ detached: true, // survive daemon exit
136
+ stdio: 'ignore',
104
137
  });
105
- });
138
+ child.unref();
139
+ watch.pid = child.pid;
140
+ } catch (err) {
141
+ // Fire synchronously via the poller path so the agent still hears about it.
142
+ watch._launchError = `Could not start the command: ${err.message}`;
143
+ }
144
+ }
145
+
146
+ // One poll iteration — checks for completion (sentinel for command mode, the
147
+ // `until` command for until mode) and wakes the agent when it's met.
148
+ _tick(watch) {
149
+ if (watch.status !== 'active') return;
150
+
151
+ if (watch._launchError) {
152
+ this._wake(watch, { outcome: 'error', summary: watch._launchError });
153
+ return;
154
+ }
106
155
 
107
- child.on('close', (code, signal) => {
108
- if (watch.status !== 'active') return; // already timed out / cancelled
109
- const output = Buffer.concat(chunks).toString('utf8').slice(-OUTPUT_TAIL).trim();
156
+ if (watch.mode === 'command') {
157
+ if (!existsSync(watch.statusFile)) return; // still running
158
+ let code = null;
159
+ try { code = parseInt(readFileSync(watch.statusFile, 'utf8').trim(), 10); } catch { return; }
160
+ if (Number.isNaN(code)) return;
110
161
  this._wake(watch, {
111
162
  outcome: code === 0 ? 'success' : 'failure',
112
163
  exitCode: code,
113
- signal,
114
- summary: signal
115
- ? `Terminated by signal ${signal}.`
116
- : `Finished with exit code ${code}.`,
117
- output,
164
+ summary: `Finished with exit code ${code}.`,
165
+ output: tailFile(watch.outFile, OUTPUT_TAIL),
118
166
  });
119
- });
120
- }
121
-
122
- // ── until mode: poll something already running ────────────────
167
+ return;
168
+ }
123
169
 
124
- _startPolling(watch) {
125
- const tick = () => {
170
+ // until mode — run the check command; exit 0 means the condition holds.
171
+ const check = spawn('/bin/sh', ['-c', watch.until], { cwd: watch.cwd, env: process.env });
172
+ const chunks = [];
173
+ check.stdout?.on('data', (b) => chunks.push(b));
174
+ check.stderr?.on('data', (b) => chunks.push(b));
175
+ check.on('error', () => { /* transient — try again next tick */ });
176
+ check.on('close', (code) => {
126
177
  if (watch.status !== 'active') return;
127
- const check = spawn('/bin/sh', ['-c', watch.until], { cwd: watch.cwd, env: process.env });
128
- const chunks = [];
129
- check.stdout?.on('data', (b) => chunks.push(b));
130
- check.stderr?.on('data', (b) => chunks.push(b));
131
- check.on('error', () => { /* transient — try again next tick */ });
132
- check.on('close', (code) => {
133
- if (watch.status !== 'active') return;
134
- if (code === 0) {
135
- const output = Buffer.concat(chunks).toString('utf8').slice(-OUTPUT_TAIL).trim();
136
- this._wake(watch, { outcome: 'success', summary: 'Your watch condition is now true.', output });
137
- }
138
- });
139
- };
140
- watch._poll = setInterval(tick, watch.pollMs);
141
- tick(); // check immediately — the condition may already hold
178
+ if (code === 0) {
179
+ const output = Buffer.concat(chunks).toString('utf8').slice(-OUTPUT_TAIL).trim();
180
+ this._wake(watch, { outcome: 'success', summary: 'Your watch condition is now true.', output });
181
+ }
182
+ });
142
183
  }
143
184
 
144
185
  // ── firing ────────────────────────────────────────────────────
@@ -157,34 +198,33 @@ export class Watcher {
157
198
  watch.status = 'fired';
158
199
  watch.firedAt = Date.now();
159
200
  watch.result = result;
160
- this._cleanup(watch);
201
+ this._disarm(watch);
161
202
 
162
203
  const message = this._composeMessage(watch, result);
163
204
 
164
- // Resolve by name, not the id we stored at creation: over a 30-minute
165
- // watch the agent has very likely been chatted with and rotated to a new
166
- // id. Names are stable across rotation, so this follows the agent; a
167
- // purged/gone agent simply doesn't resolve and the watch is undeliverable
168
- // rather than resurrecting a killed agent under a stale id.
205
+ // Resolve by name, not the id stored at creation: over a long watch the
206
+ // agent has very likely been chatted with and rotated to a new id. Names
207
+ // are stable across rotation, so this follows the agent; a purged agent
208
+ // simply doesn't resolve, rather than resurrecting a killed one.
169
209
  const target = this.daemon.registry.getAll().find((a) => a.name === watch.agentName);
170
210
  if (!target) {
171
211
  watch.status = 'undeliverable';
172
212
  watch.deliveryError = `Agent ${watch.agentName} no longer exists`;
173
213
  this.daemon.audit?.log('watch.undeliverable', { id: watch.id, reason: 'agent gone' });
174
- this.daemon.broadcast?.({ type: 'watch:fired', data: this._public(watch) });
175
- return;
176
- }
177
-
178
- try {
179
- const delivered = await deliverInstruction(this.daemon, target.id, message, { recordFeedback: false });
180
- watch.agentId = delivered.agentId;
181
- watch.status = 'delivered';
182
- } catch (err) {
183
- watch.status = 'undeliverable';
184
- watch.deliveryError = err.message;
185
- this.daemon.audit?.log('watch.undeliverable', { id: watch.id, error: err.message });
214
+ } else {
215
+ try {
216
+ const delivered = await deliverInstruction(this.daemon, target.id, message, { recordFeedback: false });
217
+ watch.agentId = delivered.agentId;
218
+ watch.status = 'delivered';
219
+ } catch (err) {
220
+ watch.status = 'undeliverable';
221
+ watch.deliveryError = err.message;
222
+ this.daemon.audit?.log('watch.undeliverable', { id: watch.id, error: err.message });
223
+ }
186
224
  }
187
225
 
226
+ this._cleanupRun(watch);
227
+ this._persist();
188
228
  this.daemon.broadcast?.({ type: 'watch:fired', data: this._public(watch) });
189
229
  }
190
230
 
@@ -197,6 +237,45 @@ export class Watcher {
197
237
  return lines.join('\n');
198
238
  }
199
239
 
240
+ // ── persistence & restart recovery ────────────────────────────
241
+
242
+ _persist() {
243
+ try {
244
+ const data = [...this.watches.values()].map((w) => this._serialize(w));
245
+ writeFileSync(this.persistPath, JSON.stringify(data, null, 2), { mode: 0o600 });
246
+ } catch { /* best effort — a lost persist just means one watch won't survive */ }
247
+ }
248
+
249
+ // Load persisted watches and re-arm the active ones. Called once at startup,
250
+ // after the registry is available. This is what lets a watch survive a daemon
251
+ // restart: the detached job kept running (or already finished), and here we
252
+ // reconnect to its sentinel.
253
+ restore() {
254
+ if (!existsSync(this.persistPath)) return 0;
255
+ let data;
256
+ try { data = JSON.parse(readFileSync(this.persistPath, 'utf8')); } catch { return 0; }
257
+ if (!Array.isArray(data)) return 0;
258
+
259
+ let rearmed = 0;
260
+ const now = Date.now();
261
+ for (const w of data) {
262
+ // Drop stale finished watches; keep recent ones for history.
263
+ if (w.status !== 'active') {
264
+ if (now - (w.firedAt || w.createdAt || 0) < HISTORY_TTL_MS) {
265
+ this.watches.set(w.id, { ...w, _poll: null, _deadline: null });
266
+ }
267
+ continue;
268
+ }
269
+ const watch = { ...w, _poll: null, _deadline: null };
270
+ this.watches.set(watch.id, watch);
271
+ this._arm(watch);
272
+ rearmed++;
273
+ }
274
+ if (rearmed > 0) console.log(`[Groove:Watcher] Restored ${rearmed} active watch(es) after restart`);
275
+ this._persist();
276
+ return rearmed;
277
+ }
278
+
200
279
  // ── lifecycle ─────────────────────────────────────────────────
201
280
 
202
281
  cancel(id, agentId = null) {
@@ -205,7 +284,10 @@ export class Watcher {
205
284
  if (agentId && watch.agentId !== agentId) return false;
206
285
  if (watch.status === 'active') {
207
286
  watch.status = 'cancelled';
208
- this._cleanup(watch);
287
+ this._disarm(watch);
288
+ this._killJob(watch);
289
+ this._cleanupRun(watch);
290
+ this._persist();
209
291
  this.daemon.broadcast?.({ type: 'watch:cancelled', data: this._public(watch) });
210
292
  }
211
293
  return true;
@@ -213,23 +295,35 @@ export class Watcher {
213
295
 
214
296
  // An agent that is killed/purged shouldn't leave watches firing into a void.
215
297
  cancelForAgent(agentId) {
298
+ let changed = false;
216
299
  for (const watch of this.watches.values()) {
217
300
  if (watch.agentId === agentId && watch.status === 'active') {
218
301
  watch.status = 'cancelled';
219
- this._cleanup(watch);
302
+ this._disarm(watch);
303
+ this._killJob(watch);
304
+ this._cleanupRun(watch);
305
+ changed = true;
220
306
  }
221
307
  }
308
+ if (changed) this._persist();
222
309
  }
223
310
 
224
- _cleanup(watch) {
311
+ _disarm(watch) {
225
312
  if (watch._deadline) { clearTimeout(watch._deadline); watch._deadline = null; }
226
313
  if (watch._poll) { clearInterval(watch._poll); watch._poll = null; }
227
- if (watch._child && watch.mode === 'command' && watch.status === 'cancelled') {
228
- // Only kill the process when the user/agent cancelled — a fired watch
229
- // means it exited on its own.
230
- try { watch._child.kill('SIGTERM'); } catch { /* already gone */ }
231
- }
232
- watch._child = null;
314
+ }
315
+
316
+ // Kill the detached job — only on explicit cancel. A fired watch means the
317
+ // job already exited on its own.
318
+ _killJob(watch) {
319
+ if (watch.mode !== 'command' || !watch.pid) return;
320
+ try { process.kill(-watch.pid, 'SIGTERM'); } // negative → the whole process group
321
+ catch { try { process.kill(watch.pid, 'SIGTERM'); } catch { /* already gone */ } }
322
+ }
323
+
324
+ _cleanupRun(watch) {
325
+ if (watch.mode !== 'command') return;
326
+ try { rmSync(resolve(this.runsDir, watch.id), { recursive: true, force: true }); } catch { /* ignore */ }
233
327
  }
234
328
 
235
329
  list(agentId = null) {
@@ -237,13 +331,21 @@ export class Watcher {
237
331
  return (agentId ? all.filter((w) => w.agentId === agentId) : all).map((w) => this._public(w));
238
332
  }
239
333
 
334
+ // Clear in-memory timers on shutdown. Deliberately does NOT kill the detached
335
+ // jobs — they must survive the restart; restore() reconnects to them.
240
336
  stop() {
241
- for (const watch of this.watches.values()) {
242
- if (watch._deadline) clearTimeout(watch._deadline);
243
- if (watch._poll) clearInterval(watch._poll);
244
- if (watch._child) { try { watch._child.kill('SIGTERM'); } catch { /* gone */ } }
245
- watch._deadline = watch._poll = watch._child = null;
246
- }
337
+ for (const watch of this.watches.values()) this._disarm(watch);
338
+ }
339
+
340
+ _serialize(w) {
341
+ return {
342
+ id: w.id, agentId: w.agentId, agentName: w.agentName, label: w.label,
343
+ mode: w.mode, command: w.command, until: w.until, cwd: w.cwd,
344
+ pollMs: w.pollMs, timeoutMs: w.timeoutMs, status: w.status,
345
+ createdAt: w.createdAt, firedAt: w.firedAt || null, result: w.result || null,
346
+ pid: w.pid || null, outFile: w.outFile || null, statusFile: w.statusFile || null,
347
+ deliveryError: w.deliveryError || null,
348
+ };
247
349
  }
248
350
 
249
351
  _public(w) {
@@ -255,4 +357,28 @@ export class Watcher {
255
357
  }
256
358
  }
257
359
 
360
+ // Single-quote for safe embedding in the runner script.
361
+ function shq(s) {
362
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
363
+ }
364
+
365
+ // Read the last `maxBytes` of a file without loading the whole thing — a
366
+ // training log can be huge.
367
+ function tailFile(path, maxBytes) {
368
+ if (!path || !existsSync(path)) return '';
369
+ let fd;
370
+ try {
371
+ fd = openSync(path, 'r');
372
+ const size = fstatSync(fd).size;
373
+ const len = Math.min(size, maxBytes);
374
+ const buf = Buffer.alloc(len);
375
+ readSync(fd, buf, 0, len, size - len);
376
+ return buf.toString('utf8').trim();
377
+ } catch {
378
+ return '';
379
+ } finally {
380
+ if (fd !== undefined) try { closeSync(fd); } catch { /* ignore */ }
381
+ }
382
+ }
383
+
258
384
  export { DEFAULT_TIMEOUT_MS, MAX_WATCHES_PER_AGENT };
@@ -6,7 +6,7 @@ import assert from 'node:assert/strict';
6
6
  import { Teams } from '../src/teams.js';
7
7
  import { Registry } from '../src/registry.js';
8
8
  import { StateManager } from '../src/state.js';
9
- import { mkdtempSync, mkdirSync } from 'fs';
9
+ import { mkdtempSync, mkdirSync, existsSync } from 'fs';
10
10
  import { join } from 'path';
11
11
  import { tmpdir } from 'os';
12
12
 
@@ -245,4 +245,51 @@ describe('Teams', () => {
245
245
  // getActiveTeam returns default team name
246
246
  assert.equal(teams.getActiveTeam(), 'Default');
247
247
  });
248
+
249
+ // A moved agent keeps the working directory of its old team. Deleting that
250
+ // team must not pull the directory out from under it.
251
+ describe('deleting a team an agent was moved out of', () => {
252
+ function movedAgentSetup() {
253
+ const teamA = teams.create('Alpha');
254
+ const teamB = teams.create('Bravo');
255
+ // Agent lives in team A's directory, then is moved to team B (teamId
256
+ // changes, workingDir does not — this is what the drag-drop move does).
257
+ const agent = daemon.registry.add({ role: 'backend', teamId: teamA.id, workingDir: teamA.workingDir });
258
+ daemon.registry.update(agent.id, { teamId: teamB.id });
259
+ return { teamA, teamB, agent };
260
+ }
261
+
262
+ it('keeps the directory on permanent delete when a moved agent still uses it', () => {
263
+ const { teamA, agent } = movedAgentSetup();
264
+ assert.ok(existsSync(teamA.workingDir));
265
+
266
+ teams.delete(teamA.id, { permanent: true });
267
+
268
+ assert.equal(teams.get(teamA.id), null, 'team record removed');
269
+ assert.ok(existsSync(teamA.workingDir), 'directory retained for the moved agent');
270
+ assert.ok(daemon.registry.get(agent.id), 'moved agent still exists');
271
+ assert.ok(existsSync(daemon.registry.get(agent.id).workingDir), 'agent working dir still valid');
272
+ });
273
+
274
+ it('keeps the directory on archive when a moved agent still uses it', () => {
275
+ const { teamA, agent } = movedAgentSetup();
276
+
277
+ teams.delete(teamA.id); // default = archive
278
+
279
+ assert.equal(teams.get(teamA.id), null);
280
+ assert.ok(existsSync(teamA.workingDir), 'directory left in place');
281
+ assert.ok(daemon.registry.get(agent.id));
282
+ });
283
+
284
+ it('still removes the directory when no external agent depends on it', () => {
285
+ const teamA = teams.create('Alpha');
286
+ const dir = teamA.workingDir;
287
+ daemon.registry.add({ role: 'backend', teamId: teamA.id, workingDir: dir });
288
+ assert.ok(existsSync(dir));
289
+
290
+ teams.delete(teamA.id, { permanent: true });
291
+
292
+ assert.ok(!existsSync(dir), 'directory removed — its only agent belonged to this team');
293
+ });
294
+ });
248
295
  });
@@ -3,16 +3,20 @@
3
3
 
4
4
  import { describe, it, beforeEach, afterEach } from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
+ import { mkdtempSync, rmSync, readFileSync } from 'fs';
7
+ import { tmpdir } from 'os';
8
+ import { resolve } from 'path';
6
9
  import { Watcher } from '../src/watcher.js';
7
10
 
8
11
  // Captures what the watcher hands to deliverInstruction — the wake message and
9
12
  // which agent it woke — without needing a real process manager.
10
- function makeDaemon() {
13
+ function makeDaemon(grooveDir) {
11
14
  const delivered = [];
12
15
  const broadcasts = [];
13
16
  return {
14
17
  delivered, broadcasts,
15
18
  projectDir: process.cwd(),
19
+ grooveDir,
16
20
  registry: {
17
21
  _agents: new Map(),
18
22
  add(a) { this._agents.set(a.id, a); return a; },
@@ -44,23 +48,26 @@ function wireDelivery(daemon) {
44
48
  const settle = (ms = 40) => new Promise((r) => setTimeout(r, ms));
45
49
 
46
50
  describe('Watcher', () => {
47
- let daemon, watcher;
51
+ let daemon, watcher, grooveDir;
48
52
 
49
53
  beforeEach(() => {
50
- daemon = makeDaemon();
54
+ grooveDir = mkdtempSync(resolve(tmpdir(), 'groove-watch-'));
55
+ daemon = makeDaemon(grooveDir);
51
56
  wireDelivery(daemon);
52
57
  watcher = new Watcher(daemon);
53
58
  daemon.watcher = watcher;
54
59
  daemon.registry.add({ id: 'a1', name: 'fullstack-1', role: 'fullstack', provider: 'claude-code' });
55
60
  });
56
61
 
57
- // Clear any lingering timers/intervals/children an active poll watch has a
58
- // 30-minute deadline that would otherwise keep the test runner alive.
59
- afterEach(() => watcher.stop());
62
+ // Clear lingering timers so the runner can exit, then remove the temp dir.
63
+ afterEach(() => {
64
+ watcher.stop();
65
+ try { rmSync(grooveDir, { recursive: true, force: true }); } catch { /* ignore */ }
66
+ });
60
67
 
61
68
  it('runs a command and wakes the agent with a success outcome', async () => {
62
69
  watcher.create('a1', { command: 'echo hello && exit 0', label: 'greeting' });
63
- await settle(200);
70
+ await settle(800);
64
71
 
65
72
  assert.equal(daemon.delivered.length, 1);
66
73
  const { agentId, message } = daemon.delivered[0];
@@ -72,7 +79,7 @@ describe('Watcher', () => {
72
79
 
73
80
  it('reports a non-zero exit as a failure with output', async () => {
74
81
  watcher.create('a1', { command: 'echo boom >&2 && exit 3', label: 'build' });
75
- await settle(200);
82
+ await settle(800);
76
83
 
77
84
  const { message } = daemon.delivered[0];
78
85
  assert.match(message, /exit code 3/);
@@ -84,7 +91,7 @@ describe('Watcher', () => {
84
91
  // Agent rotates: same name, new id, old id gone — exactly what resume does.
85
92
  daemon.registry._agents.delete('a1');
86
93
  daemon.registry.add({ id: 'a1-r1', name: 'fullstack-1', role: 'fullstack', provider: 'claude-code' });
87
- await settle(200);
94
+ await settle(800);
88
95
 
89
96
  assert.equal(daemon.delivered.length, 1);
90
97
  assert.equal(daemon.delivered[0].agentId, 'a1-r1');
@@ -93,7 +100,7 @@ describe('Watcher', () => {
93
100
  it('marks undeliverable when the agent is gone, without throwing', async () => {
94
101
  watcher.create('a1', { command: 'exit 0', label: 'x' });
95
102
  daemon.registry._agents.delete('a1'); // purged, no replacement
96
- await settle(200);
103
+ await settle(800);
97
104
 
98
105
  assert.equal(daemon.delivered.length, 0);
99
106
  const w = watcher.list()[0];
@@ -155,4 +162,55 @@ describe('Watcher', () => {
155
162
  watcher.cancelForAgent('a1');
156
163
  assert.ok(watcher.list().every((w) => w.status === 'cancelled'));
157
164
  });
165
+
166
+ // ── surviving a daemon restart ────────────────────────────────
167
+
168
+ it('re-arms an active watch after a simulated restart and still fires', async () => {
169
+ // A long job is launched and the daemon "restarts" before it finishes.
170
+ const flag = resolve(grooveDir, 'done.flag');
171
+ watcher.create('a1', { command: `sleep 1 && touch ${flag} && exit 0`, label: 'training' });
172
+ watcher.stop(); // simulate daemon going down — detached job keeps running
173
+
174
+ // New daemon instance, same grooveDir — restore reconnects to the job.
175
+ const daemon2 = makeDaemon(grooveDir);
176
+ wireDelivery(daemon2);
177
+ const watcher2 = new Watcher(daemon2);
178
+ daemon2.watcher = watcher2;
179
+ daemon2.registry.add({ id: 'a1', name: 'fullstack-1', role: 'fullstack', provider: 'claude-code' });
180
+
181
+ const rearmed = watcher2.restore();
182
+ assert.equal(rearmed, 1, 'the active watch was restored');
183
+
184
+ await settle(2000); // let the detached job finish and the sentinel appear
185
+ assert.equal(daemon2.delivered.length, 1, 'the restored watch fired after restart');
186
+ assert.match(daemon2.delivered[0].message, /training/);
187
+ assert.match(daemon2.delivered[0].message, /exit code 0/);
188
+ watcher2.stop();
189
+ });
190
+
191
+ it('fires immediately on restore if the job finished while the daemon was down', async () => {
192
+ watcher.create('a1', { command: 'exit 0', label: 'quick' });
193
+ await settle(300); // let the detached job write its sentinel
194
+ watcher.stop(); // "restart" AFTER completion, before it was noticed
195
+ daemon.delivered.length = 0;
196
+
197
+ const daemon2 = makeDaemon(grooveDir);
198
+ wireDelivery(daemon2);
199
+ const watcher2 = new Watcher(daemon2);
200
+ daemon2.watcher = watcher2;
201
+ daemon2.registry.add({ id: 'a1', name: 'fullstack-1', role: 'fullstack', provider: 'claude-code' });
202
+
203
+ watcher2.restore();
204
+ await settle(80); // immediate tick on re-arm sees the sentinel
205
+ assert.equal(daemon2.delivered.length, 1, 'completed-during-downtime job still delivered');
206
+ watcher2.stop();
207
+ });
208
+
209
+ it('persists watches to disk', () => {
210
+ watcher.create('a1', { command: 'sleep 5', label: 'persisted' });
211
+ const raw = JSON.parse(readFileSync(resolve(grooveDir, 'watches.json'), 'utf8'));
212
+ assert.equal(raw.length, 1);
213
+ assert.equal(raw[0].label, 'persisted');
214
+ assert.equal(raw[0].status, 'active');
215
+ });
158
216
  });