groove-dev 0.27.184 → 0.27.186

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 (60) hide show
  1. package/.watch-test-flag-1784767927904 +1 -0
  2. package/.watch-test-flag-1784768055729 +1 -0
  3. package/.watch-test-flag-1784768066364 +1 -0
  4. package/node_modules/@groove-dev/cli/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/package.json +1 -1
  6. package/node_modules/@groove-dev/daemon/src/api.js +3 -0
  7. package/node_modules/@groove-dev/daemon/src/index.js +17 -0
  8. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +60 -5
  9. package/node_modules/@groove-dev/daemon/src/innerchat.js +167 -54
  10. package/node_modules/@groove-dev/daemon/src/introducer.js +3 -2
  11. package/node_modules/@groove-dev/daemon/src/journalist.js +83 -42
  12. package/node_modules/@groove-dev/daemon/src/process.js +19 -10
  13. package/node_modules/@groove-dev/daemon/src/rotator.js +6 -1
  14. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +80 -28
  15. package/node_modules/@groove-dev/daemon/src/routes/watch.js +42 -0
  16. package/node_modules/@groove-dev/daemon/src/watcher.js +258 -0
  17. package/node_modules/@groove-dev/daemon/test/innerchat.test.js +71 -1
  18. package/node_modules/@groove-dev/daemon/test/journalist.test.js +59 -0
  19. package/node_modules/@groove-dev/daemon/test/rotator.test.js +22 -4
  20. package/node_modules/@groove-dev/daemon/test/watcher.test.js +158 -0
  21. package/node_modules/@groove-dev/gui/dist/assets/index-CU8L_r5f.css +1 -0
  22. package/node_modules/@groove-dev/gui/dist/assets/{index-BpOyN6Zf.js → index-DOOaCFRS.js} +227 -222
  23. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  24. package/node_modules/@groove-dev/gui/package.json +1 -1
  25. package/node_modules/@groove-dev/gui/src/app.css +1 -0
  26. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +92 -4
  27. package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +25 -0
  28. package/node_modules/@groove-dev/gui/src/lib/logpaths.js +45 -0
  29. package/node_modules/@groove-dev/gui/src/stores/groove.js +30 -16
  30. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +6 -0
  31. package/node_modules/@groove-dev/gui/src/stores/slices/ui-slice.js +11 -0
  32. package/node_modules/@groove-dev/gui/src/views/memory.jsx +56 -31
  33. package/package.json +1 -1
  34. package/packages/cli/package.json +1 -1
  35. package/packages/daemon/package.json +1 -1
  36. package/packages/daemon/src/api.js +3 -0
  37. package/packages/daemon/src/index.js +17 -0
  38. package/packages/daemon/src/innerchat-docs.js +60 -5
  39. package/packages/daemon/src/innerchat.js +167 -54
  40. package/packages/daemon/src/introducer.js +3 -2
  41. package/packages/daemon/src/journalist.js +83 -42
  42. package/packages/daemon/src/process.js +19 -10
  43. package/packages/daemon/src/rotator.js +6 -1
  44. package/packages/daemon/src/routes/innerchat.js +80 -28
  45. package/packages/daemon/src/routes/watch.js +42 -0
  46. package/packages/daemon/src/watcher.js +258 -0
  47. package/packages/gui/dist/assets/index-CU8L_r5f.css +1 -0
  48. package/packages/gui/dist/assets/{index-BpOyN6Zf.js → index-DOOaCFRS.js} +227 -222
  49. package/packages/gui/dist/index.html +2 -2
  50. package/packages/gui/package.json +1 -1
  51. package/packages/gui/src/app.css +1 -0
  52. package/packages/gui/src/components/agents/agent-feed.jsx +92 -4
  53. package/packages/gui/src/components/editor/terminal.jsx +25 -0
  54. package/packages/gui/src/lib/logpaths.js +45 -0
  55. package/packages/gui/src/stores/groove.js +30 -16
  56. package/packages/gui/src/stores/slices/agents-slice.js +6 -0
  57. package/packages/gui/src/stores/slices/ui-slice.js +11 -0
  58. package/packages/gui/src/views/memory.jsx +56 -31
  59. package/node_modules/@groove-dev/gui/dist/assets/index-DiXB7yry.css +0 -1
  60. package/packages/gui/dist/assets/index-DiXB7yry.css +0 -1
@@ -0,0 +1,258 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import { spawn } from 'child_process';
4
+ import { randomUUID } from 'crypto';
5
+ import { deliverInstruction } from './deliver.js';
6
+
7
+ // Watches are bounded so a wedged process or a condition that never comes true
8
+ // can't poll forever or hold a slot indefinitely.
9
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
10
+ const MAX_TIMEOUT_MS = 6 * 60 * 60 * 1000;
11
+ const DEFAULT_POLL_MS = 15 * 1000;
12
+ const MIN_POLL_MS = 3 * 1000;
13
+ const MAX_WATCHES_PER_AGENT = 5;
14
+ const OUTPUT_TAIL = 4000;
15
+
16
+ /**
17
+ * Wake-on-completion for agents.
18
+ *
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.
24
+ *
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).
30
+ */
31
+ export class Watcher {
32
+ constructor(daemon) {
33
+ this.daemon = daemon;
34
+ this.watches = new Map();
35
+ }
36
+
37
+ create(agentId, opts = {}) {
38
+ const agent = this.daemon.registry.get(agentId);
39
+ if (!agent) throw new Error('Agent not found');
40
+
41
+ const command = typeof opts.command === 'string' ? opts.command.trim() : '';
42
+ const until = typeof opts.until === 'string' ? opts.until.trim() : '';
43
+ if (!command && !until) throw new Error('Provide either "command" (run it) or "until" (poll it)');
44
+ if (command && until) throw new Error('Provide only one of "command" or "until"');
45
+
46
+ const active = [...this.watches.values()].filter((w) => w.agentId === agentId && w.status === 'active');
47
+ if (active.length >= MAX_WATCHES_PER_AGENT) {
48
+ throw new Error(`You already have ${MAX_WATCHES_PER_AGENT} active watches — cancel one before adding another`);
49
+ }
50
+
51
+ const timeoutMs = Math.min(Number(opts.timeoutMs) || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
52
+ const watch = {
53
+ id: randomUUID().slice(0, 12),
54
+ agentId,
55
+ agentName: agent.name,
56
+ label: (opts.label && String(opts.label).slice(0, 120)) || command || until,
57
+ mode: command ? 'command' : 'until',
58
+ command, until,
59
+ cwd: agent.workingDir || this.daemon.projectDir,
60
+ pollMs: Math.max(Number(opts.intervalMs) || DEFAULT_POLL_MS, MIN_POLL_MS),
61
+ timeoutMs,
62
+ status: 'active',
63
+ createdAt: Date.now(),
64
+ _child: null,
65
+ _poll: null,
66
+ _deadline: null,
67
+ };
68
+
69
+ 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);
73
+
74
+ this.daemon.audit?.log('watch.create', { id: watch.id, agent: agentId, mode: watch.mode });
75
+ this.daemon.broadcast?.({ type: 'watch:created', data: this._public(watch) });
76
+ return this._public(watch);
77
+ }
78
+
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;
88
+
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);
99
+
100
+ child.on('error', (err) => {
101
+ this._wake(watch, {
102
+ outcome: 'error',
103
+ summary: `Could not start the command: ${err.message}`,
104
+ });
105
+ });
106
+
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();
110
+ this._wake(watch, {
111
+ outcome: code === 0 ? 'success' : 'failure',
112
+ exitCode: code,
113
+ signal,
114
+ summary: signal
115
+ ? `Terminated by signal ${signal}.`
116
+ : `Finished with exit code ${code}.`,
117
+ output,
118
+ });
119
+ });
120
+ }
121
+
122
+ // ── until mode: poll something already running ────────────────
123
+
124
+ _startPolling(watch) {
125
+ const tick = () => {
126
+ 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
142
+ }
143
+
144
+ // ── firing ────────────────────────────────────────────────────
145
+
146
+ _fireTimeout(watch) {
147
+ if (watch.status !== 'active') return;
148
+ this._wake(watch, {
149
+ outcome: 'timeout',
150
+ summary: `Your watch "${watch.label}" hit its ${Math.round(watch.timeoutMs / 60000)}-minute time limit `
151
+ + 'without finishing. It may still be running — check on it directly.',
152
+ });
153
+ }
154
+
155
+ async _wake(watch, result) {
156
+ if (watch.status !== 'active') return;
157
+ watch.status = 'fired';
158
+ watch.firedAt = Date.now();
159
+ watch.result = result;
160
+ this._cleanup(watch);
161
+
162
+ const message = this._composeMessage(watch, result);
163
+
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.
169
+ const target = this.daemon.registry.getAll().find((a) => a.name === watch.agentName);
170
+ if (!target) {
171
+ watch.status = 'undeliverable';
172
+ watch.deliveryError = `Agent ${watch.agentName} no longer exists`;
173
+ 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 });
186
+ }
187
+
188
+ this.daemon.broadcast?.({ type: 'watch:fired', data: this._public(watch) });
189
+ }
190
+
191
+ _composeMessage(watch, result) {
192
+ const lines = [`[Watch fired — "${watch.label}"]`, '', result.summary];
193
+ if (result.output) {
194
+ lines.push('', 'Output (tail):', '```', result.output, '```');
195
+ }
196
+ lines.push('', 'This is the notification you set up earlier. Continue from here and report to the user.');
197
+ return lines.join('\n');
198
+ }
199
+
200
+ // ── lifecycle ─────────────────────────────────────────────────
201
+
202
+ cancel(id, agentId = null) {
203
+ const watch = this.watches.get(id);
204
+ if (!watch) return false;
205
+ if (agentId && watch.agentId !== agentId) return false;
206
+ if (watch.status === 'active') {
207
+ watch.status = 'cancelled';
208
+ this._cleanup(watch);
209
+ this.daemon.broadcast?.({ type: 'watch:cancelled', data: this._public(watch) });
210
+ }
211
+ return true;
212
+ }
213
+
214
+ // An agent that is killed/purged shouldn't leave watches firing into a void.
215
+ cancelForAgent(agentId) {
216
+ for (const watch of this.watches.values()) {
217
+ if (watch.agentId === agentId && watch.status === 'active') {
218
+ watch.status = 'cancelled';
219
+ this._cleanup(watch);
220
+ }
221
+ }
222
+ }
223
+
224
+ _cleanup(watch) {
225
+ if (watch._deadline) { clearTimeout(watch._deadline); watch._deadline = null; }
226
+ 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;
233
+ }
234
+
235
+ list(agentId = null) {
236
+ const all = [...this.watches.values()].sort((a, b) => b.createdAt - a.createdAt);
237
+ return (agentId ? all.filter((w) => w.agentId === agentId) : all).map((w) => this._public(w));
238
+ }
239
+
240
+ 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
+ }
247
+ }
248
+
249
+ _public(w) {
250
+ return {
251
+ id: w.id, agentId: w.agentId, agentName: w.agentName, label: w.label,
252
+ mode: w.mode, command: w.command, until: w.until, status: w.status,
253
+ createdAt: w.createdAt, firedAt: w.firedAt || null, result: w.result || null,
254
+ };
255
+ }
256
+ }
257
+
258
+ export { DEFAULT_TIMEOUT_MS, MAX_WATCHES_PER_AGENT };
@@ -1,7 +1,7 @@
1
1
  // GROOVE — InnerChat Tests
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import { describe, it, beforeEach } from 'node:test';
4
+ import { describe, it, beforeEach, afterEach } from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
6
  import { InnerChat, MAX_EXCHANGES } from '../src/innerchat.js';
7
7
 
@@ -72,6 +72,9 @@ describe('InnerChat', () => {
72
72
  }
73
73
  });
74
74
 
75
+ // Clear any un-awaited ask/tell timers so the runner can exit.
76
+ afterEach(() => innerchat.stop());
77
+
75
78
  // ── The blocking round trip ───────────────────────────────
76
79
 
77
80
  it('blocks until the target answers, then resolves with the reply', async () => {
@@ -259,4 +262,71 @@ describe('InnerChat', () => {
259
262
  assert.ok(daemon.audits.some((a) => a.type === 'innerchat.ask'));
260
263
  assert.ok(daemon.audits.some((a) => a.type === 'innerchat.answer'));
261
264
  });
265
+
266
+ // ── tell: non-blocking send ───────────────────────────────
267
+
268
+ it('tell returns immediately without waiting for a reply', async () => {
269
+ const res = await innerchat.tell('a1', 'a2', 'heads up, spec is on disk');
270
+ assert.equal(res.delivered, true);
271
+ assert.ok(res.threadId);
272
+
273
+ // Delivered, framed as non-blocking.
274
+ const msg = daemon.sent.at(-1);
275
+ assert.equal(msg.id, 'a2');
276
+ assert.match(msg.msg, /sent you a message/);
277
+ assert.match(msg.msg, /NOT blocked/);
278
+ });
279
+
280
+ it('routes an async reply back to the sender, resuming it if needed', async () => {
281
+ await innerchat.tell('a1', 'a2', 'when you get a sec, confirm the error shape');
282
+ // a1 ended its turn: stopped, no loop.
283
+ daemon.processes._loops.delete('a1');
284
+ daemon.processes._running.delete('a1');
285
+
286
+ innerchat.onAgentOutput('a2', result('confirmed: {code, message}'));
287
+ await tick();
288
+
289
+ // Reply delivered to a1 — via resume, since it had ended.
290
+ const resume = daemon.resumes.at(-1);
291
+ assert.equal(resume.id, 'a1');
292
+ assert.match(resume.msg, /InnerChat reply from fullstack-14/);
293
+ assert.match(resume.msg, /confirmed: \{code, message\}/);
294
+ assert.match(resume.msg, /you were not blocking/);
295
+ });
296
+
297
+ it('does not block the sender or set blockedOn for a tell', async () => {
298
+ await innerchat.tell('a1', 'a2', 'fyi');
299
+ assert.equal(innerchat.blockedOn.get('a1'), undefined);
300
+ assert.ok(innerchat.getPending('a2'), 'still capturing the eventual reply');
301
+ });
302
+
303
+ it('a tell to a target already awaited is refused', async () => {
304
+ // Left blocking; afterEach stop() rejects it — swallow so it isn't unhandled.
305
+ innerchat.ask('a1', 'a2', 'blocking q').catch(() => {});
306
+ await tick();
307
+ await assert.rejects(() => innerchat.tell('a1', 'a2', 'second'), /unanswered message/);
308
+ });
309
+
310
+ it('ask and tell share the exchange budget', async () => {
311
+ const r1 = await innerchat.tell('a1', 'a2', 'one');
312
+ assert.equal(r1.exchanges, 1);
313
+ // a2 replies to the tell, freeing the slot.
314
+ innerchat.onAgentOutput('a2', result('ok'));
315
+ await tick();
316
+
317
+ const p = innerchat.ask('a1', 'a2', 'two');
318
+ await tick();
319
+ innerchat.onAgentOutput('a2', result('answer'));
320
+ const r2 = await p;
321
+ assert.equal(r2.exchanges, 2, 'tell + ask both count');
322
+ });
323
+
324
+ it('an async sender that vanishes is dropped quietly', async () => {
325
+ await innerchat.tell('a1', 'a2', 'x');
326
+ daemon.registry._agents.delete('a1'); // sender gone entirely
327
+ // Reply arrives with nobody to route to — must not throw.
328
+ innerchat.onAgentOutput('a2', result('late answer'));
329
+ await tick();
330
+ assert.equal(innerchat.getPending('a2'), null);
331
+ });
262
332
  });
@@ -234,6 +234,65 @@ describe('Journalist', () => {
234
234
  });
235
235
  });
236
236
 
237
+ describe('conversation resume', () => {
238
+ // Build a log whose first user message is the task assignment, followed by
239
+ // enough later dialogue that a small budget truncates the early turns away.
240
+ function writeLongConversation(grooveDir, agentName, originalTask, fillerTurns = 20) {
241
+ const lines = [
242
+ JSON.stringify({ type: 'user', message: { content: originalTask } }),
243
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'Understood — starting on the original task now, here is my plan.' }] } }),
244
+ ];
245
+ for (let i = 0; i < fillerTurns; i++) {
246
+ lines.push(JSON.stringify({ type: 'user', message: { content: `Follow-up question number ${i}: ${'detail '.repeat(50)}` } }));
247
+ lines.push(JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: `Answer to follow-up ${i}: ${'context '.repeat(50)}` }] } }));
248
+ }
249
+ writeFileSync(join(grooveDir, 'logs', `${agentName}.log`), lines.join('\n'));
250
+ }
251
+
252
+ const agentFixture = {
253
+ id: 'arch-id-1', name: 'arch-1', role: 'backend',
254
+ provider: 'claude-code', scope: ['src/**'], workingDir: '/tmp',
255
+ };
256
+
257
+ it('rotation resume prompt forbids mentioning the rotation to the user', () => {
258
+ const { daemon, grooveDir } = createMockDaemon();
259
+ const journalist = new Journalist(daemon);
260
+ writeLongConversation(grooveDir, 'arch-1', 'Build the training pipeline', 2);
261
+
262
+ const prompt = journalist.buildConversationResumePrompt(agentFixture, '', { isRotation: true, reason: 'replay_ceiling' });
263
+
264
+ assert.ok(prompt, 'should build a resume prompt');
265
+ assert.ok(prompt.includes('NEVER mention the rotation'), 'must instruct the agent to keep the rotation invisible');
266
+ });
267
+
268
+ it('anchors the original task from the full log even when the thread window truncates it', () => {
269
+ const { daemon, grooveDir } = createMockDaemon();
270
+ // Small budget: only recent filler turns survive in the thread window
271
+ daemon.config = { resumeBudgetChars: 3000 };
272
+ const journalist = new Journalist(daemon);
273
+ const originalTask = 'Build a mixture-of-experts LLM training pipeline from scratch';
274
+ writeLongConversation(grooveDir, 'arch-1', originalTask, 20);
275
+
276
+ // Sanity: the truncated window no longer contains the original task
277
+ const thread = journalist.extractConversationThread(agentFixture);
278
+ assert.ok(thread, 'should extract a thread');
279
+ assert.ok(!thread.includes(originalTask), 'test setup: original task must be truncated out of the window');
280
+
281
+ const prompt = journalist.buildConversationResumePrompt(agentFixture, '', { isRotation: true, reason: 'replay_ceiling' });
282
+ assert.ok(prompt.includes(originalTask), 'original task must still anchor the prompt via the full log');
283
+ });
284
+
285
+ it('honors resumeBudgetChars config for the thread window', () => {
286
+ const { daemon, grooveDir } = createMockDaemon();
287
+ daemon.config = { resumeBudgetChars: 2000 };
288
+ const journalist = new Journalist(daemon);
289
+ writeLongConversation(grooveDir, 'arch-1', 'Build the training pipeline', 20);
290
+
291
+ const thread = journalist.extractConversationThread(agentFixture);
292
+ assert.ok(thread.length <= 2500, `thread should respect the configured budget, got ${thread.length}`);
293
+ });
294
+ });
295
+
237
296
  describe('status', () => {
238
297
  it('should report status correctly', () => {
239
298
  const { daemon } = createMockDaemon();
@@ -457,7 +457,7 @@ describe('Rotator', () => {
457
457
  const agent = {
458
458
  id: 'rc1', name: 'claude-chat', role: 'backend', status: 'running',
459
459
  provider: 'claude-code', scope: [], model: null,
460
- tokensUsed: 100_000, contextUsage: 0.55, workingDir: '/tmp',
460
+ tokensUsed: 100_000, contextUsage: 0.85, workingDir: '/tmp',
461
461
  lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(), // idle 5 min
462
462
  spawnedAt: new Date(Date.now() - 600_000).toISOString(),
463
463
  };
@@ -470,22 +470,40 @@ describe('Rotator', () => {
470
470
  assert.equal(history[0].reason, 'replay_ceiling');
471
471
  });
472
472
 
473
+ it('should honor a configured replayCeiling below the default', async () => {
474
+ const agent = {
475
+ id: 'rc5', name: 'claude-cheap', role: 'backend', status: 'running',
476
+ provider: 'claude-code', scope: [], model: null,
477
+ tokensUsed: 100_000, contextUsage: 0.55, workingDir: '/tmp',
478
+ lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(),
479
+ spawnedAt: new Date(Date.now() - 600_000).toISOString(),
480
+ };
481
+ mockDaemon.registry.agents = [agent];
482
+ mockDaemon.config = { replayCeiling: 0.5 };
483
+
484
+ await rotator.check();
485
+
486
+ const history = rotator.getHistory();
487
+ assert.equal(history.length, 1);
488
+ assert.equal(history[0].reason, 'replay_ceiling');
489
+ });
490
+
473
491
  it('should NOT replay-ceiling rotate below the ceiling or during cooldown', async () => {
474
492
  const agent = {
475
493
  id: 'rc2', name: 'claude-chat-2', role: 'backend', status: 'running',
476
494
  provider: 'claude-code', scope: [], model: null,
477
- tokensUsed: 100_000, contextUsage: 0.45, workingDir: '/tmp',
495
+ tokensUsed: 100_000, contextUsage: 0.75, workingDir: '/tmp',
478
496
  lastActivity: new Date(Date.now() - 5 * 60_000).toISOString(),
479
497
  spawnedAt: new Date(Date.now() - 600_000).toISOString(),
480
498
  };
481
499
  mockDaemon.registry.agents = [agent];
482
500
 
483
- // Below ceiling — no rotation
501
+ // Below ceiling (0.75 < 0.80 default) — no rotation
484
502
  await rotator.check();
485
503
  assert.equal(rotator.getHistory().length, 0);
486
504
 
487
505
  // Above ceiling but on cooldown — still no rotation
488
- agent.contextUsage = 0.60;
506
+ agent.contextUsage = 0.85;
489
507
  rotator.lastRotationTime.set('rc2', Date.now() - 60_000);
490
508
  await rotator.check();
491
509
  assert.equal(rotator.getHistory().length, 0);
@@ -0,0 +1,158 @@
1
+ // GROOVE — Watcher Tests
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { describe, it, beforeEach, afterEach } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { Watcher } from '../src/watcher.js';
7
+
8
+ // Captures what the watcher hands to deliverInstruction — the wake message and
9
+ // which agent it woke — without needing a real process manager.
10
+ function makeDaemon() {
11
+ const delivered = [];
12
+ const broadcasts = [];
13
+ return {
14
+ delivered, broadcasts,
15
+ projectDir: process.cwd(),
16
+ registry: {
17
+ _agents: new Map(),
18
+ add(a) { this._agents.set(a.id, a); return a; },
19
+ get(id) { return this._agents.get(id) || null; },
20
+ getAll() { return [...this._agents.values()]; },
21
+ },
22
+ audit: { log() {} },
23
+ broadcast(m) { broadcasts.push(m); },
24
+ // Stand-in for deliverInstruction's daemon-level delivery.
25
+ _deliver(agentId, message) { delivered.push({ agentId, message }); return { agentId }; },
26
+ };
27
+ }
28
+
29
+ // Point deliverInstruction at our capture by giving the agent a live loop the
30
+ // real deliver.js would use — simpler here to stub the module boundary.
31
+ // The watcher imports deliverInstruction directly, so we exercise it through a
32
+ // registry whose agent looks "running with a loop" and a process manager that
33
+ // records the send.
34
+ function wireDelivery(daemon) {
35
+ daemon.processes = {
36
+ hasAgentLoop: () => true,
37
+ isRunning: () => true,
38
+ async sendMessage(id, msg) { daemon._deliver(id, msg); return true; },
39
+ queueMessage(id, msg) { daemon._deliver(id, msg); },
40
+ };
41
+ daemon.locks = { release() {} };
42
+ }
43
+
44
+ const settle = (ms = 40) => new Promise((r) => setTimeout(r, ms));
45
+
46
+ describe('Watcher', () => {
47
+ let daemon, watcher;
48
+
49
+ beforeEach(() => {
50
+ daemon = makeDaemon();
51
+ wireDelivery(daemon);
52
+ watcher = new Watcher(daemon);
53
+ daemon.watcher = watcher;
54
+ daemon.registry.add({ id: 'a1', name: 'fullstack-1', role: 'fullstack', provider: 'claude-code' });
55
+ });
56
+
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());
60
+
61
+ it('runs a command and wakes the agent with a success outcome', async () => {
62
+ watcher.create('a1', { command: 'echo hello && exit 0', label: 'greeting' });
63
+ await settle(200);
64
+
65
+ assert.equal(daemon.delivered.length, 1);
66
+ const { agentId, message } = daemon.delivered[0];
67
+ assert.equal(agentId, 'a1');
68
+ assert.match(message, /Watch fired — "greeting"/);
69
+ assert.match(message, /exit code 0/);
70
+ assert.match(message, /hello/);
71
+ });
72
+
73
+ it('reports a non-zero exit as a failure with output', async () => {
74
+ watcher.create('a1', { command: 'echo boom >&2 && exit 3', label: 'build' });
75
+ await settle(200);
76
+
77
+ const { message } = daemon.delivered[0];
78
+ assert.match(message, /exit code 3/);
79
+ assert.match(message, /boom/);
80
+ });
81
+
82
+ it('resolves the agent by name so a rotation to a new id still gets woken', async () => {
83
+ watcher.create('a1', { command: 'exit 0', label: 'x' });
84
+ // Agent rotates: same name, new id, old id gone — exactly what resume does.
85
+ daemon.registry._agents.delete('a1');
86
+ daemon.registry.add({ id: 'a1-r1', name: 'fullstack-1', role: 'fullstack', provider: 'claude-code' });
87
+ await settle(200);
88
+
89
+ assert.equal(daemon.delivered.length, 1);
90
+ assert.equal(daemon.delivered[0].agentId, 'a1-r1');
91
+ });
92
+
93
+ it('marks undeliverable when the agent is gone, without throwing', async () => {
94
+ watcher.create('a1', { command: 'exit 0', label: 'x' });
95
+ daemon.registry._agents.delete('a1'); // purged, no replacement
96
+ await settle(200);
97
+
98
+ assert.equal(daemon.delivered.length, 0);
99
+ const w = watcher.list()[0];
100
+ assert.equal(w.status, 'undeliverable');
101
+ });
102
+
103
+ it('fires an "until" watch when the condition already holds (immediate check)', async () => {
104
+ const fs = await import('fs');
105
+ const flag = `${process.cwd()}/.watch-test-flag-${Date.now()}`;
106
+ fs.writeFileSync(flag, 'x');
107
+ try {
108
+ // create() checks once immediately before waiting for the poll interval.
109
+ watcher.create('a1', { until: `test -f ${flag}`, label: 'flag present' });
110
+ await settle(120);
111
+ assert.equal(daemon.delivered.length, 1, 'woke because the condition was already true');
112
+ assert.match(daemon.delivered[0].message, /condition is now true/);
113
+ } finally {
114
+ try { fs.unlinkSync(flag); } catch { /* ignore */ }
115
+ }
116
+ });
117
+
118
+ it('does not fire an until-watch while the condition stays false', async () => {
119
+ watcher.create('a1', { until: 'exit 1', label: 'never', intervalMs: 3000 });
120
+ await settle(80);
121
+ assert.equal(daemon.delivered.length, 0);
122
+ assert.equal(watcher.list()[0].status, 'active');
123
+ });
124
+
125
+ it('fires a timeout when the command runs too long', async () => {
126
+ watcher.create('a1', { command: 'sleep 5', label: 'slow', timeoutMs: 60 });
127
+ await settle(160);
128
+
129
+ const { message } = daemon.delivered[0];
130
+ assert.match(message, /time limit/);
131
+ });
132
+
133
+ it('rejects bad input', () => {
134
+ assert.throws(() => watcher.create('a1', {}), /Provide either/);
135
+ assert.throws(() => watcher.create('a1', { command: 'x', until: 'y' }), /only one/);
136
+ assert.throws(() => watcher.create('nope', { command: 'x' }), /Agent not found/);
137
+ });
138
+
139
+ it('caps active watches per agent', () => {
140
+ for (let i = 0; i < 5; i++) watcher.create('a1', { command: 'sleep 5', label: `w${i}` });
141
+ assert.throws(() => watcher.create('a1', { command: 'sleep 5' }), /already have 5/);
142
+ });
143
+
144
+ it('cancels a watch and stops its process', async () => {
145
+ const w = watcher.create('a1', { command: 'sleep 5', label: 'cancel me' });
146
+ assert.equal(watcher.cancel(w.id), true);
147
+ assert.equal(watcher.list()[0].status, 'cancelled');
148
+ await settle(100);
149
+ assert.equal(daemon.delivered.length, 0, 'a cancelled watch never wakes the agent');
150
+ });
151
+
152
+ it('cancels all of an agents watches when it is removed', () => {
153
+ watcher.create('a1', { command: 'sleep 5', label: 'a' });
154
+ watcher.create('a1', { command: 'sleep 5', label: 'b' });
155
+ watcher.cancelForAgent('a1');
156
+ assert.ok(watcher.list().every((w) => w.status === 'cancelled'));
157
+ });
158
+ });