groove-dev 0.27.207 → 0.27.208

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 (34) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/axom-connector.js +50 -2
  4. package/node_modules/@groove-dev/daemon/src/axom-remote.js +16 -14
  5. package/node_modules/@groove-dev/daemon/src/axom-runtimes.js +301 -0
  6. package/node_modules/@groove-dev/daemon/src/axom-server.js +23 -4
  7. package/node_modules/@groove-dev/daemon/src/index.js +3 -1
  8. package/node_modules/@groove-dev/daemon/src/routes/axom.js +73 -0
  9. package/node_modules/@groove-dev/daemon/src/routes/watch.js +11 -0
  10. package/node_modules/@groove-dev/daemon/src/watcher.js +55 -2
  11. package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +44 -1
  12. package/node_modules/@groove-dev/daemon/test/axom-runtimes.test.js +198 -0
  13. package/node_modules/@groove-dev/daemon/test/watcher.test.js +68 -2
  14. package/node_modules/@groove-dev/gui/dist/assets/{index-OzNfp6-Y.js → index-3Lzv2-To.js} +234 -234
  15. package/node_modules/@groove-dev/gui/dist/assets/index-Bh_HF8ed.css +1 -0
  16. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  17. package/node_modules/@groove-dev/gui/package.json +1 -1
  18. package/package.json +1 -1
  19. package/packages/cli/package.json +1 -1
  20. package/packages/daemon/package.json +1 -1
  21. package/packages/daemon/src/axom-connector.js +50 -2
  22. package/packages/daemon/src/axom-remote.js +16 -14
  23. package/packages/daemon/src/axom-runtimes.js +301 -0
  24. package/packages/daemon/src/axom-server.js +23 -4
  25. package/packages/daemon/src/index.js +3 -1
  26. package/packages/daemon/src/routes/axom.js +73 -0
  27. package/packages/daemon/src/routes/watch.js +11 -0
  28. package/packages/daemon/src/watcher.js +55 -2
  29. package/packages/gui/dist/assets/{index-OzNfp6-Y.js → index-3Lzv2-To.js} +234 -234
  30. package/packages/gui/dist/assets/index-Bh_HF8ed.css +1 -0
  31. package/packages/gui/dist/index.html +2 -2
  32. package/packages/gui/package.json +1 -1
  33. package/node_modules/@groove-dev/gui/dist/assets/index-DG6yq4dB.css +0 -1
  34. package/packages/gui/dist/assets/index-DG6yq4dB.css +0 -1
@@ -59,6 +59,28 @@ export class Watcher {
59
59
  if (!command && !until) throw new Error('Provide either "command" (run it) or "until" (poll it)');
60
60
  if (command && until) throw new Error('Provide only one of "command" or "until"');
61
61
 
62
+ // Re-attach, never re-execute. A daemon restart resumes the agent mid-turn,
63
+ // so it re-issues the watch it thinks never completed — and a second copy of
64
+ // a long job (training, benchmark) launched against the same working dir
65
+ // corrupts the first one's output and competes for the GPU. If an identical
66
+ // command is still running for this agent, hand back the existing watch.
67
+ // This is the daemon-side equivalent of an flock, and it has to live here
68
+ // because the agent cannot know a duplicate already exists.
69
+ if (command) {
70
+ const dup = [...this.watches.values()].find(
71
+ (w) => w.status === 'active'
72
+ && w.mode === 'command'
73
+ && w.command === command
74
+ && (w.agentName === agent.name || w.agentId === agentId)
75
+ && this._jobAlive(w),
76
+ );
77
+ if (dup) {
78
+ this.daemon.audit?.log('watch.duplicate', { id: dup.id, agent: agentId, label: dup.label });
79
+ console.log(`[Groove:Watcher] Re-attached to running watch ${dup.id} instead of re-running: ${dup.label}`);
80
+ return { ...this._public(dup), reattached: true };
81
+ }
82
+ }
83
+
62
84
  const active = [...this.watches.values()].filter((w) => w.agentId === agentId && w.status === 'active');
63
85
  if (active.length >= MAX_WATCHES_PER_AGENT) {
64
86
  throw new Error(`You already have ${MAX_WATCHES_PER_AGENT} active watches — cancel one before adding another`);
@@ -118,11 +140,14 @@ export class Watcher {
118
140
  // Run the command in a SUBSHELL so its own `exit N` can't kill the wrapper
119
141
  // before the sentinel is written; tee output to a file, then atomically
120
142
  // publish the exit code so the poller never sees a half-written value.
143
+ // Append rather than truncate: if this script is ever run twice, `>` would
144
+ // erase the first run's record — exactly the evidence you need to work out
145
+ // what happened. Appending keeps both.
121
146
  const script = [
122
147
  '#!/bin/sh',
123
148
  '(',
124
149
  watch.command,
125
- `) > ${shq(watch.outFile)} 2>&1`,
150
+ `) >> ${shq(watch.outFile)} 2>&1`,
126
151
  `echo $? > ${shq(watch.statusFile)}.tmp && mv ${shq(watch.statusFile)}.tmp ${shq(watch.statusFile)}`,
127
152
  '',
128
153
  ].join('\n');
@@ -143,6 +168,16 @@ export class Watcher {
143
168
  }
144
169
  }
145
170
 
171
+ // Is this watch's detached job still running? Signal 0 tests for existence
172
+ // without delivering anything. A finished job (sentinel present) is not alive
173
+ // even if the pid happens to have been recycled by another process.
174
+ _jobAlive(watch) {
175
+ if (watch.mode !== 'command') return false;
176
+ if (watch.statusFile && existsSync(watch.statusFile)) return false; // already exited
177
+ if (!watch.pid) return false;
178
+ try { process.kill(watch.pid, 0); return true; } catch { return false; }
179
+ }
180
+
146
181
  // One poll iteration — checks for completion (sentinel for command mode, the
147
182
  // `until` command for until mode) and wakes the agent when it's met.
148
183
  _tick(watch) {
@@ -257,6 +292,7 @@ export class Watcher {
257
292
  if (!Array.isArray(data)) return 0;
258
293
 
259
294
  let rearmed = 0;
295
+ let lost = 0;
260
296
  const now = Date.now();
261
297
  for (const w of data) {
262
298
  // Drop stale finished watches; keep recent ones for history.
@@ -268,10 +304,27 @@ export class Watcher {
268
304
  }
269
305
  const watch = { ...w, _poll: null, _deadline: null };
270
306
  this.watches.set(watch.id, watch);
307
+
308
+ // A command watch whose job is gone with no exit sentinel died while the
309
+ // daemon was down. Polling it would just burn until the timeout and then
310
+ // report "may still be running" — say what actually happened instead.
311
+ if (watch.mode === 'command' && watch.pid
312
+ && !existsSync(watch.statusFile || '') && !this._jobAlive(watch)) {
313
+ lost++;
314
+ this._wake(watch, {
315
+ outcome: 'error',
316
+ summary: `The job for "${watch.label}" is no longer running and never recorded an exit code — `
317
+ + 'it was lost while the daemon was down. Check its output before assuming it finished.',
318
+ output: tailFile(watch.outFile, OUTPUT_TAIL),
319
+ });
320
+ continue;
321
+ }
322
+
271
323
  this._arm(watch);
272
324
  rearmed++;
273
325
  }
274
- if (rearmed > 0) console.log(`[Groove:Watcher] Restored ${rearmed} active watch(es) after restart`);
326
+ if (rearmed > 0) console.log(`[Groove:Watcher] Re-attached to ${rearmed} running watch(es) after restart`);
327
+ if (lost > 0) console.log(`[Groove:Watcher] ${lost} watch(es) lost their job while the daemon was down`);
275
328
  this._persist();
276
329
  return rearmed;
277
330
  }
@@ -34,6 +34,8 @@ class MockBridge {
34
34
  this.shutdowns = [];
35
35
  this.messages = [];
36
36
  this.sinceSeen = []; // ?since values observed on WS connects
37
+ this.epochsSeen = [];
38
+ this.epoch = 'epoch-A';
37
39
  this.sockets = new Set();
38
40
  }
39
41
 
@@ -50,8 +52,12 @@ class MockBridge {
50
52
  ws.on('close', () => this.sockets.delete(ws));
51
53
  const since = url.searchParams.get('since');
52
54
  this.sinceSeen.push(since);
55
+ this.epochsSeen.push(url.searchParams.get('epoch'));
56
+ // §16.4: hello first; a stale client epoch voids `since` (full replay).
57
+ ws.send(JSON.stringify({ kind: 'ws_hello', payload: { epoch: this.epoch, since_honored: url.searchParams.get('epoch') === this.epoch } }));
58
+ const staleEpoch = url.searchParams.get('epoch') && url.searchParams.get('epoch') !== this.epoch;
53
59
  // Ring replay: everything after `since`, then live.
54
- const from = since ? parseInt(since.slice(3), 10) : 0;
60
+ const from = (since && !staleEpoch) ? parseInt(since.slice(3), 10) : 0;
55
61
  for (const e of session.events) {
56
62
  if (parseInt(e.id.slice(3), 10) > from) ws.send(JSON.stringify(e));
57
63
  }
@@ -435,6 +441,43 @@ describe('AxomConnector', () => {
435
441
  assert.equal(latest.data.endpoints[0].sessions[0].live, false);
436
442
  });
437
443
 
444
+ it('§16.4: a changed epoch resets the cursor — a runtime restart is replayed, never swallowed', async () => {
445
+ connect();
446
+ await waitFor(() => connector.status().endpoints[0]?.sessions[0]?.watching);
447
+ bridge.emit('s-test0001', envelope(1, 'pipeline_start'));
448
+ bridge.emit('s-test0001', envelope(2, 'thought'));
449
+ await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event').length === 2);
450
+
451
+ // Runtime "restarts": new epoch, event ids reset to 1, fresh history.
452
+ bridge.epoch = 'epoch-B';
453
+ bridge.sessions['s-test0001'].events = [envelope(1, 'pipeline_start', { fresh: true }), envelope(2, 'narration', { text: 'post-restart' })];
454
+ bridge.sessions['s-test0001'].socket.terminate();
455
+
456
+ // Reconnect: our epoch-A + since goes up, hello says epoch-B → we reset
457
+ // and take the full replay. Without the reset, monotonic dedup would
458
+ // silently drop both replayed events (ids <= lastSeq).
459
+ await waitFor(() => daemon.broadcasts.some((b) => b.type === 'axom:session:reset'), 5000);
460
+ await waitFor(() => daemon.broadcasts.filter((b) => b.type === 'axom:event' && b.envelope.payload?.fresh).length === 1, 5000);
461
+ const s = connector.status().endpoints[0].sessions[0];
462
+ assert.equal(s.buffered, 2); // the ring holds ONLY post-restart history
463
+ assert.ok(bridge.epochsSeen.includes('epoch-A')); // we did present the old epoch
464
+ });
465
+
466
+ it('recheck collapses a stale connected state the moment the runtime is gone', async () => {
467
+ connect();
468
+ await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
469
+ daemon.broadcasts.length = 0;
470
+
471
+ // Runtime dies (a deliberate stop); without recheck the endpoint would
472
+ // stay 'connected' until the next scheduled session poll failed.
473
+ await bridge.close();
474
+ connector.recheck('local');
475
+ await waitFor(() => connector.status().endpoints[0]?.status === 'error');
476
+ // The transition broadcast rode the recheck — the GUI card moves with
477
+ // the verb, not with a poll.
478
+ assert.ok(daemon.broadcasts.some((b) => b.type === 'axom:status'));
479
+ });
480
+
438
481
  it('reports an unreachable endpoint honestly and recovers by retry', async () => {
439
482
  const deadUrl = bridge.url;
440
483
  const port = Number(new URL(deadUrl).port);
@@ -0,0 +1,198 @@
1
+ // GROOVE — Axom Runtime Model Tests
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // The model is exercised with faked backends: what matters here is that verbs
5
+ // dispatch on control mode, states derive honestly, and migration folds the
6
+ // legacy keys without forking anyone's ledger.
7
+
8
+ import { describe, it, beforeEach } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { mkdtempSync } from 'fs';
11
+ import { join } from 'path';
12
+ import { tmpdir } from 'os';
13
+ import { AxomRuntimes, validateRuntime } from '../src/axom-runtimes.js';
14
+
15
+ function fakeDaemon() {
16
+ const calls = { remote: [], server: [], connector: [], nudges: [], rechecks: [] };
17
+ const broadcasts = [];
18
+ return {
19
+ calls,
20
+ broadcasts,
21
+ grooveDir: mkdtempSync(join(tmpdir(), 'groove-axrt-')),
22
+ config: { axom: {} },
23
+ broadcast(m) { broadcasts.push(m); },
24
+ audit: { log() {} },
25
+ axom: {
26
+ endpoints: new Map(),
27
+ configure(entries) { calls.connector.push(entries); },
28
+ nudge(name) { calls.nudges.push(name); },
29
+ recheck(name) { calls.rechecks.push(name); },
30
+ },
31
+ axomRemote: {
32
+ async status(cfg) { calls.remote.push(['status', cfg]); return fakeDaemon._remoteStatus || { running: false }; },
33
+ async start(cfg) { calls.remote.push(['start', cfg]); return { started: true }; },
34
+ async stop(o, cfg) { calls.remote.push(['stop', o, cfg]); return { stopped: true, via: 'shutdown' }; },
35
+ async ensureTunnel(cfg) { calls.remote.push(['tunnel', cfg]); return { tunneled: true }; },
36
+ },
37
+ axomServer: {
38
+ instances: [],
39
+ list() { return this.instances; },
40
+ async start(id, opts) { calls.server.push(['start', id, opts]); return { id, port: 8737, status: 'running' }; },
41
+ async stop(id) { calls.server.push(['stop', id]); },
42
+ },
43
+ };
44
+ }
45
+
46
+ const SSH_RT = {
47
+ id: 'spark', name: 'Spark', url: 'http://127.0.0.1:8737', control: 'ssh',
48
+ ssh: { host: 'spark.local', user: 'axom' },
49
+ launch: { command: 'cd /x && PYTHONPATH=model python3 -u -m axom.cli serve --cpu' },
50
+ };
51
+
52
+ describe('AxomRuntimes', () => {
53
+ let daemon, model;
54
+
55
+ beforeEach(() => {
56
+ daemon = fakeDaemon();
57
+ model = new AxomRuntimes(daemon);
58
+ // The real broadcastStatus probes runtime URLs; record the push instead so
59
+ // these tests stay hermetic. The genuine payload has its own test below.
60
+ model.broadcastStatus = async () => { daemon.broadcasts.push({ type: 'axom:runtimes' }); };
61
+ });
62
+
63
+ it('migrates the legacy endpoint+remote pair into one ssh runtime', () => {
64
+ daemon.config.axom = {
65
+ endpoints: [{ name: 'local', url: 'http://127.0.0.1:8737' }],
66
+ remote: { host: 'edgexpert-aaa6.local', user: 'axom', port: 8737, command: 'python3 -m axom.cli serve', logPath: '/x.log' },
67
+ };
68
+ assert.equal(model.migrate(), true);
69
+ const [rt] = model.list();
70
+ assert.equal(rt.control, 'ssh');
71
+ assert.equal(rt.url, 'http://127.0.0.1:8737');
72
+ assert.equal(rt.ssh.host, 'edgexpert-aaa6.local');
73
+ assert.equal(rt.launch.command, 'python3 -m axom.cli serve');
74
+ assert.equal(model.activeId(), rt.id);
75
+ assert.equal(model.migrate(), false); // idempotent
76
+ });
77
+
78
+ it('migrates a lone endpoint into a connect-only runtime', () => {
79
+ daemon.config.axom = { endpoints: [{ name: 'other', url: 'http://127.0.0.1:9999' }] };
80
+ model.migrate();
81
+ assert.equal(model.list()[0].control, 'none');
82
+ });
83
+
84
+ it('start dispatches by control: ssh starts remotely and heals the tunnel', async () => {
85
+ model.add(SSH_RT);
86
+ await model.startRuntime('spark');
87
+ const kinds = daemon.calls.remote.map((c) => c[0]);
88
+ assert.ok(kinds.includes('start'));
89
+ assert.ok(kinds.includes('tunnel')); // reachability follows lifecycle
90
+ const cfg = daemon.calls.remote.find((c) => c[0] === 'start')[1];
91
+ assert.equal(cfg.host, 'spark.local');
92
+ assert.equal(cfg.command, SSH_RT.launch.command); // spec passed VERBATIM
93
+ });
94
+
95
+ it('start dispatches by control: local spawns and adopts the resulting port as its URL', async () => {
96
+ model.add({ id: 'here', name: 'This machine', control: 'local', launch: { command: 'axom' }, dataDir: '/home/axom/axom-serve/data' });
97
+ await model.startRuntime('here');
98
+ const [, id, opts] = daemon.calls.server[0];
99
+ assert.equal(id, 'here');
100
+ assert.equal(opts.dataDir, '/home/axom/axom-serve/data'); // ADOPTS the ledger
101
+ assert.equal(model.get('here').url, 'http://127.0.0.1:8737');
102
+ });
103
+
104
+ it('refuses lifecycle verbs on connect-only runtimes with an honest message', async () => {
105
+ model.add({ id: 'theirs', name: 'Theirs', url: 'http://127.0.0.1:7000', control: 'none' });
106
+ await assert.rejects(() => model.startRuntime('theirs'), /not controlled by GROOVE/);
107
+ await assert.rejects(() => model.stopRuntime('theirs'), /not controlled by GROOVE/);
108
+ await assert.rejects(() => model.heal('theirs'), /only ssh runtimes/);
109
+ });
110
+
111
+ it('derives ssh states honestly: tunnel-down vs stopped vs host-unreachable', async () => {
112
+ model.add({ ...SSH_RT, url: 'http://127.0.0.1:1' }); // nothing listens on :1
113
+ fakeDaemon._remoteStatus = { running: true };
114
+ assert.equal((await model.state('spark')).state, 'unreachable'); // up on host, tunnel down
115
+ fakeDaemon._remoteStatus = { running: false };
116
+ assert.equal((await model.state('spark')).state, 'stopped');
117
+ fakeDaemon._remoteStatus = { running: null, error: 'no route to host' };
118
+ const s = await model.state('spark');
119
+ assert.equal(s.state, 'unreachable');
120
+ assert.match(s.detail, /no route/);
121
+ delete fakeDaemon._remoteStatus;
122
+ });
123
+
124
+ it('reports connected when the connector has the stream', async () => {
125
+ model.add(SSH_RT);
126
+ daemon.axom.endpoints.set('spark', { status: 'connected' });
127
+ assert.equal((await model.state('spark')).state, 'connected');
128
+ });
129
+
130
+ it('status exposes exactly the verbs each state supports', async () => {
131
+ model.add({ id: 'theirs', name: 'Theirs', url: 'http://127.0.0.1:1', control: 'none' });
132
+ const { runtimes } = await model.status();
133
+ const theirs = runtimes.find((r) => r.id === 'theirs');
134
+ assert.equal(theirs.canStart, false);
135
+ assert.equal(theirs.canStop, false);
136
+ assert.equal(theirs.canHeal, false);
137
+ });
138
+
139
+ it('keeps the connector in sync with the runtime list', () => {
140
+ model.add(SSH_RT);
141
+ model.remove('spark');
142
+ const last = daemon.calls.connector.pop();
143
+ assert.deepEqual(last, []);
144
+ });
145
+
146
+ // Axom-UX flag 1: the GUI's runtime cards must move on events, not a poll.
147
+ it('pushes fresh runtime state on every mutation and lifecycle verb', async () => {
148
+ const seen = () => daemon.broadcasts.filter((b) => b.type === 'axom:runtimes').length;
149
+ model.add(SSH_RT);
150
+ assert.ok(seen() >= 1);
151
+ let n = seen();
152
+ model.update('spark', { name: 'Spark 2' });
153
+ assert.ok(seen() > n);
154
+ n = seen();
155
+ model.activate('spark');
156
+ assert.ok(seen() > n);
157
+ n = seen();
158
+ await model.startRuntime('spark');
159
+ assert.ok(seen() > n);
160
+ // Axom-UX flag 3: start pulls the connector in NOW — 'running' must not
161
+ // linger for a backoff cycle before becoming 'connected'.
162
+ assert.deepEqual(daemon.calls.nudges, ['spark']);
163
+ n = seen();
164
+ await model.stopRuntime('spark');
165
+ assert.ok(seen() > n);
166
+ // ...and stop re-probes so a stale 'connected' collapses with the verb.
167
+ assert.deepEqual(daemon.calls.rechecks, ['spark']);
168
+ n = seen();
169
+ model.remove('spark');
170
+ assert.ok(seen() > n);
171
+ });
172
+
173
+ it('broadcastStatus emits the full axom:runtimes payload', async () => {
174
+ const d = fakeDaemon();
175
+ const m = new AxomRuntimes(d);
176
+ m.add({ id: 'here', name: 'This machine', control: 'local' }); // no URL → no probe
177
+ const deadline = Date.now() + 2000;
178
+ while (!d.broadcasts.some((b) => b.type === 'axom:runtimes')) {
179
+ if (Date.now() > deadline) throw new Error('no axom:runtimes broadcast');
180
+ await new Promise((r) => setTimeout(r, 10));
181
+ }
182
+ const msg = d.broadcasts.find((b) => b.type === 'axom:runtimes');
183
+ assert.equal(msg.data.activeRuntimeId, 'here');
184
+ assert.equal(msg.data.runtimes[0].state, 'stopped');
185
+ assert.equal(msg.data.runtimes[0].canStart, true);
186
+ });
187
+ });
188
+
189
+ describe('validateRuntime', () => {
190
+ it('accepts the three shapes and rejects garbage', () => {
191
+ assert.equal(validateRuntime(SSH_RT), null);
192
+ assert.equal(validateRuntime({ id: 'x', name: 'X', control: 'local' }), null); // url comes from spawn
193
+ assert.equal(validateRuntime({ id: 'y', name: 'Y', url: 'http://127.0.0.1:1', control: 'none' }), null);
194
+ assert.ok(validateRuntime({ id: 'z', name: 'Z', control: 'teleport' }));
195
+ assert.ok(validateRuntime({ id: 'z', name: 'Z', control: 'none' })); // none requires url
196
+ assert.ok(validateRuntime({ id: 'z', name: 'Z', control: 'ssh', url: 'http://127.0.0.1:1', ssh: { host: 'h;rm', user: 'u' } }));
197
+ });
198
+ });
@@ -143,9 +143,11 @@ describe('Watcher', () => {
143
143
  assert.throws(() => watcher.create('nope', { command: 'x' }), /Agent not found/);
144
144
  });
145
145
 
146
+ // Distinct commands — identical ones now re-attach to the running job rather
147
+ // than stacking up, so they would never reach the cap.
146
148
  it('caps active watches per agent', () => {
147
- for (let i = 0; i < 5; i++) watcher.create('a1', { command: 'sleep 5', label: `w${i}` });
148
- assert.throws(() => watcher.create('a1', { command: 'sleep 5' }), /already have 5/);
149
+ for (let i = 0; i < 5; i++) watcher.create('a1', { command: `sleep 5 # ${i}`, label: `w${i}` });
150
+ assert.throws(() => watcher.create('a1', { command: 'sleep 5 # 6' }), /already have 5/);
149
151
  });
150
152
 
151
153
  it('cancels a watch and stops its process', async () => {
@@ -206,6 +208,70 @@ describe('Watcher', () => {
206
208
  watcher2.stop();
207
209
  });
208
210
 
211
+ // ── no double-launch (regression: daemon rebuild re-ran a live job) ──
212
+ //
213
+ // A daemon restart resumes the agent mid-turn, so it re-issues the watch it
214
+ // believes never completed. Launching a second copy of a long job corrupts
215
+ // the first one's output and competes for the same hardware.
216
+
217
+ it('re-attaches instead of launching a second copy of a running command', async () => {
218
+ const marker = resolve(grooveDir, 'runs.txt');
219
+ const cmd = `echo run >> ${marker}; sleep 3`;
220
+
221
+ const first = watcher.create('a1', { command: cmd, label: 'champion v11' });
222
+ await settle(300);
223
+
224
+ const second = watcher.create('a1', { command: cmd, label: 'champion v11' });
225
+ assert.equal(second.id, first.id, 'the same watch is returned, not a new one');
226
+ assert.equal(second.reattached, true, 'the caller is told it re-attached');
227
+ assert.equal(
228
+ watcher.list().filter((w) => w.status === 'active').length, 1,
229
+ 'only one active watch exists',
230
+ );
231
+
232
+ await settle(400);
233
+ const runs = readFileSync(marker, 'utf8').trim().split('\n').filter(Boolean);
234
+ assert.equal(runs.length, 1, 'the command executed exactly once');
235
+ });
236
+
237
+ it('does not re-attach once the original job has exited', async () => {
238
+ const cmd = 'exit 0';
239
+ const first = watcher.create('a1', { command: cmd, label: 'short' });
240
+ await settle(500); // job finishes and the watch fires
241
+
242
+ const second = watcher.create('a1', { command: cmd, label: 'short again' });
243
+ assert.notEqual(second.id, first.id, 'a finished job may legitimately be re-run');
244
+ assert.ok(!second.reattached);
245
+ });
246
+
247
+ it('reports a job that vanished while the daemon was down instead of polling to timeout', async () => {
248
+ const w = watcher.create('a1', { command: 'sleep 30', label: 'doomed' });
249
+ await settle(200);
250
+ watcher._killJob(watcher.watches.get(w.id)); // job dies during the outage
251
+ watcher.stop();
252
+ await settle(200);
253
+
254
+ const daemon2 = makeDaemon(grooveDir);
255
+ wireDelivery(daemon2);
256
+ const watcher2 = new Watcher(daemon2);
257
+ daemon2.watcher = watcher2;
258
+ daemon2.registry.add({ id: 'a1', name: 'fullstack-1', role: 'fullstack', provider: 'claude-code' });
259
+
260
+ watcher2.restore();
261
+ await settle(120);
262
+ assert.equal(daemon2.delivered.length, 1, 'the agent is told the job was lost');
263
+ assert.match(daemon2.delivered[0].message, /lost while the daemon was down/);
264
+ watcher2.stop();
265
+ });
266
+
267
+ it('appends to the run log so a re-run cannot erase the record', () => {
268
+ const w = watcher.create('a1', { command: 'echo hi', label: 'log' });
269
+ const script = readFileSync(resolve(grooveDir, 'watch-runs', w.id, 'run.sh'), 'utf8');
270
+ const outLine = script.split('\n').find((l) => l.includes('out.log'));
271
+ assert.ok(outLine.includes('>>'), 'the output redirect appends');
272
+ assert.ok(!/[^>]>\s*'[^']*out\.log/.test(outLine), 'the output log is never truncated');
273
+ });
274
+
209
275
  it('persists watches to disk', () => {
210
276
  watcher.create('a1', { command: 'sleep 5', label: 'persisted' });
211
277
  const raw = JSON.parse(readFileSync(resolve(grooveDir, 'watches.json'), 'utf8'));