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
@@ -0,0 +1,301 @@
1
+ // GROOVE — Axom Runtime Model (the redesign's one entity)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // A `runtime` is the single thing the GUI reasons about: a named Axom with a
5
+ // URL and a control mode. Everything else (connector endpoints, spawned
6
+ // instances, SSH lifecycle, tunnels) is a backend behind this model.
7
+ //
8
+ // control: 'local' — a process THIS daemon spawns/kills (AxomServerManager)
9
+ // control: 'ssh' — start/stop over SSH on a machine the user owns
10
+ // control: 'none' — someone else's runtime; connect-only
11
+ //
12
+ // States, each owning one recovery action (plans/axom-runtime-flow-redesign.md):
13
+ // connected → events flowing (verb: stop / disconnect)
14
+ // running → /about answers, WS catching up (no verb needed)
15
+ // stopped → reachable, no runtime (verb: start, if controllable)
16
+ // unreachable → can't reach the URL/host (verb: heal tunnel / retry)
17
+ // `unknown` appears only when probing itself failed — never guessed away.
18
+
19
+ import { validateEndpoint } from './axom-connector.js';
20
+ import { validateRemote } from './axom-remote.js';
21
+ import { saveConfig } from './firstrun.js';
22
+
23
+ export function validateRuntime(rt) {
24
+ if (!rt || typeof rt !== 'object') return 'runtime must be an object';
25
+ if (!rt.id || !/^[a-zA-Z0-9_-]{1,40}$/.test(rt.id)) return 'invalid runtime id';
26
+ if (!rt.name || typeof rt.name !== 'string' || rt.name.length > 60) return 'invalid runtime name';
27
+ if (!['local', 'ssh', 'none'].includes(rt.control)) return 'control must be local, ssh, or none';
28
+ if (rt.control !== 'local' || rt.url) {
29
+ // local runtimes get their URL from the spawned port; others must have one
30
+ const problem = validateEndpoint({ name: rt.id, url: rt.url });
31
+ if (rt.control !== 'local' && problem) return problem;
32
+ }
33
+ if (rt.control === 'ssh') {
34
+ const problem = validateRemote({ port: undefined, ...rt.ssh });
35
+ if (problem) return `ssh config: ${problem}`;
36
+ }
37
+ if (rt.launch !== undefined) {
38
+ if (typeof rt.launch !== 'object' || typeof rt.launch.command !== 'string'
39
+ || rt.launch.command.length === 0 || rt.launch.command.length > 500) {
40
+ return 'launch.command must be a non-empty string of at most 500 chars';
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ export class AxomRuntimes {
47
+ constructor(daemon) {
48
+ this.daemon = daemon;
49
+ }
50
+
51
+ // ── Config ────────────────────────────────────────────────────────────────
52
+
53
+ _cfg() {
54
+ if (!this.daemon.config.axom) this.daemon.config.axom = {};
55
+ return this.daemon.config.axom;
56
+ }
57
+
58
+ list() {
59
+ return this._cfg().runtimes || [];
60
+ }
61
+
62
+ get(id) {
63
+ return this.list().find((r) => r.id === id) || null;
64
+ }
65
+
66
+ activeId() {
67
+ const cfg = this._cfg();
68
+ return cfg.activeRuntimeId && this.get(cfg.activeRuntimeId)
69
+ ? cfg.activeRuntimeId
70
+ : this.list()[0]?.id || null;
71
+ }
72
+
73
+ // One-time, idempotent: fold the four legacy keys into runtimes[]. Old keys
74
+ // are kept until the model proves out — compat routes still read them.
75
+ migrate() {
76
+ const cfg = this._cfg();
77
+ if (Array.isArray(cfg.runtimes)) return false;
78
+ const runtimes = [];
79
+ const remote = cfg.remote || null;
80
+ const ep = (cfg.endpoints || [])[0] || null;
81
+ if (ep && remote && ep.url.endsWith(`:${remote.port || 8737}`)) {
82
+ runtimes.push({
83
+ id: 'spark', name: remote.host.split('.')[0] || 'Remote', url: ep.url,
84
+ control: 'ssh',
85
+ ssh: { host: remote.host, user: remote.user, sshPort: remote.sshPort, autoTunnel: true },
86
+ launch: remote.command ? { command: remote.command } : undefined,
87
+ logPath: remote.logPath,
88
+ });
89
+ } else if (ep) {
90
+ let name = 'Axom';
91
+ try { name = new URL(ep.url).hostname; } catch { /* keep default */ }
92
+ runtimes.push({ id: ep.name || 'axom', name, url: ep.url, control: 'none' });
93
+ }
94
+ cfg.runtimes = runtimes;
95
+ if (runtimes.length && !cfg.activeRuntimeId) cfg.activeRuntimeId = runtimes[0].id;
96
+ return true;
97
+ }
98
+
99
+ _save() {
100
+ saveConfig(this.daemon.grooveDir, this.daemon.config);
101
+ }
102
+
103
+ start() {
104
+ this.migrate();
105
+ this._syncConnector();
106
+ }
107
+
108
+ // Every runtime with a URL becomes a connector endpoint — the connector
109
+ // stays the single owner of event streams.
110
+ _syncConnector() {
111
+ const entries = this.list()
112
+ .filter((r) => r.url)
113
+ .map((r) => ({ name: r.id, url: r.url }));
114
+ this.daemon.axom.configure(entries);
115
+ }
116
+
117
+ add(rt) {
118
+ const problem = validateRuntime(rt);
119
+ if (problem) throw new Error(problem);
120
+ if (this.get(rt.id)) throw new Error(`runtime "${rt.id}" already exists`);
121
+ this._cfg().runtimes = [...this.list(), rt];
122
+ if (!this._cfg().activeRuntimeId) this._cfg().activeRuntimeId = rt.id;
123
+ this._save();
124
+ this._syncConnector();
125
+ this.broadcastStatus();
126
+ return rt;
127
+ }
128
+
129
+ update(id, patch) {
130
+ const existing = this.get(id);
131
+ if (!existing) throw new Error(`no runtime "${id}"`);
132
+ const next = { ...existing, ...patch, id };
133
+ const problem = validateRuntime(next);
134
+ if (problem) throw new Error(problem);
135
+ this._cfg().runtimes = this.list().map((r) => (r.id === id ? next : r));
136
+ this._save();
137
+ this._syncConnector();
138
+ this.broadcastStatus();
139
+ return next;
140
+ }
141
+
142
+ remove(id) {
143
+ if (!this.get(id)) throw new Error(`no runtime "${id}"`);
144
+ this._cfg().runtimes = this.list().filter((r) => r.id !== id);
145
+ if (this._cfg().activeRuntimeId === id) {
146
+ this._cfg().activeRuntimeId = this.list()[0]?.id || null;
147
+ }
148
+ this._save();
149
+ this._syncConnector();
150
+ this.broadcastStatus();
151
+ }
152
+
153
+ activate(id) {
154
+ if (!this.get(id)) throw new Error(`no runtime "${id}"`);
155
+ this._cfg().activeRuntimeId = id;
156
+ this._save();
157
+ this.broadcastStatus();
158
+ }
159
+
160
+ // ── State derivation ──────────────────────────────────────────────────────
161
+
162
+ async state(id) {
163
+ const rt = this.get(id);
164
+ if (!rt) throw new Error(`no runtime "${id}"`);
165
+ const ep = rt.url ? this.daemon.axom.endpoints.get(rt.id) : null;
166
+
167
+ if (ep?.status === 'connected') {
168
+ return { state: 'connected', detail: null };
169
+ }
170
+ // Probe the URL directly — the connector's backoff may simply not have
171
+ // caught up yet, and "running" beats a stale "error".
172
+ if (rt.url) {
173
+ try {
174
+ const res = await fetch(`${rt.url}/about`, { signal: AbortSignal.timeout(3000) });
175
+ if (res.ok) {
176
+ // It answers — pull the connector in NOW instead of letting its
177
+ // backoff stretch the 'running' limbo into a felt stall.
178
+ this.daemon.axom.nudge?.(rt.id);
179
+ return { state: 'running', detail: 'connecting to event stream' };
180
+ }
181
+ } catch (err) {
182
+ const refused = /ECONNREFUSED/.test(err.cause?.code || err.message || '');
183
+ if (refused && rt.control !== 'ssh') {
184
+ return { state: 'stopped', detail: null };
185
+ }
186
+ }
187
+ }
188
+ if (rt.control === 'ssh') {
189
+ // The host knows more than the tunnel does.
190
+ const remote = await this.daemon.axomRemote.status(this._sshCfg(rt));
191
+ if (remote.running === true) return { state: 'unreachable', detail: 'runtime is up on the host — the tunnel is down' };
192
+ if (remote.running === false) return { state: 'stopped', detail: null };
193
+ return { state: 'unreachable', detail: remote.error || `can't reach ${rt.ssh.host}` };
194
+ }
195
+ if (rt.control === 'local') {
196
+ const inst = this.daemon.axomServer.list().find((i) => i.id === rt.id);
197
+ if (inst?.status === 'running') return { state: 'running', detail: 'connecting to event stream' };
198
+ return { state: 'stopped', detail: inst?.error || null };
199
+ }
200
+ return { state: 'unreachable', detail: 'nothing answers at this endpoint' };
201
+ }
202
+
203
+ _broadcasting = false;
204
+ async broadcastStatus() {
205
+ if (this._broadcasting) return; // status() probes; don't stampede
206
+ this._broadcasting = true;
207
+ try {
208
+ this.daemon.broadcast({ type: 'axom:runtimes', data: await this.status() });
209
+ } catch { /* next state change rebroadcasts */ } finally {
210
+ this._broadcasting = false;
211
+ }
212
+ }
213
+
214
+ async status() {
215
+ const runtimes = await Promise.all(this.list().map(async (rt) => {
216
+ let derived;
217
+ try {
218
+ derived = await this.state(rt.id);
219
+ } catch (err) {
220
+ derived = { state: 'unknown', detail: err.message };
221
+ }
222
+ const ep = this.daemon.axom.endpoints.get(rt.id);
223
+ return {
224
+ id: rt.id,
225
+ name: rt.name,
226
+ control: rt.control,
227
+ url: rt.url || null,
228
+ ...derived,
229
+ about: ep?.about || null,
230
+ error: ep?.error || null,
231
+ canStart: rt.control !== 'none' && derived.state === 'stopped',
232
+ canStop: rt.control !== 'none' && (derived.state === 'connected' || derived.state === 'running'),
233
+ canHeal: rt.control === 'ssh' && derived.state === 'unreachable',
234
+ };
235
+ }));
236
+ return { runtimes, activeRuntimeId: this.activeId() };
237
+ }
238
+
239
+ // ── Verbs — dispatch on control, never guess ─────────────────────────────
240
+
241
+ _sshCfg(rt) {
242
+ let port = 8737;
243
+ try { port = Number(new URL(rt.url).port) || 8737; } catch { /* default */ }
244
+ return {
245
+ ...rt.ssh,
246
+ port,
247
+ command: rt.launch?.command,
248
+ logPath: rt.logPath,
249
+ };
250
+ }
251
+
252
+ async startRuntime(id) {
253
+ const rt = this.get(id);
254
+ if (!rt) throw new Error(`no runtime "${id}"`);
255
+ if (rt.control === 'none') throw new Error(`"${rt.name}" is not controlled by GROOVE — start it where it runs`);
256
+ if (rt.control === 'ssh') {
257
+ const result = await this.daemon.axomRemote.start(this._sshCfg(rt));
258
+ if (rt.ssh?.autoTunnel !== false) await this.daemon.axomRemote.ensureTunnel(this._sshCfg(rt));
259
+ this._syncConnector();
260
+ this.daemon.axom.nudge?.(rt.id);
261
+ this.broadcastStatus();
262
+ return result;
263
+ }
264
+ // local: the spawned port becomes the runtime's URL.
265
+ const instance = await this.daemon.axomServer.start(rt.id, {
266
+ launch: rt.launch,
267
+ dataDir: rt.dataDir,
268
+ });
269
+ this.update(id, { url: `http://127.0.0.1:${instance.port}` });
270
+ this.daemon.axom.nudge?.(rt.id);
271
+ return { started: true, port: instance.port };
272
+ }
273
+
274
+ async stopRuntime(id, { force = false } = {}) {
275
+ const rt = this.get(id);
276
+ if (!rt) throw new Error(`no runtime "${id}"`);
277
+ if (rt.control === 'none') throw new Error(`"${rt.name}" is not controlled by GROOVE`);
278
+ let result;
279
+ if (rt.control === 'ssh') {
280
+ result = await this.daemon.axomRemote.stop({ force }, this._sshCfg(rt));
281
+ } else {
282
+ await this.daemon.axomServer.stop(rt.id);
283
+ result = { stopped: true };
284
+ }
285
+ // The connector still believes 'connected' until its next poll fails —
286
+ // re-probe now so the runtime card moves with the verb, not the poll.
287
+ this.daemon.axom.recheck?.(rt.id);
288
+ this.broadcastStatus();
289
+ return result;
290
+ }
291
+
292
+ async heal(id) {
293
+ const rt = this.get(id);
294
+ if (!rt) throw new Error(`no runtime "${id}"`);
295
+ if (rt.control !== 'ssh') throw new Error('only ssh runtimes have a tunnel to heal');
296
+ const result = await this.daemon.axomRemote.ensureTunnel(this._sshCfg(rt));
297
+ this.daemon.axom.nudge?.(rt.id);
298
+ this.broadcastStatus();
299
+ return result;
300
+ }
301
+ }
@@ -106,7 +106,14 @@ export class AxomServerManager {
106
106
 
107
107
  // Start a local instance. `id` doubles as the data-dir name, so the same id
108
108
  // across restarts resumes the same sovereign memory.
109
- async start(id = 'default') {
109
+ // opts.launch: {command, cwd?, env?} — a SPEC, not a binary name (source
110
+ // checkouts launch as `python3 -u -m axom.cli ...` with cwd+env; the
111
+ // installed default is just `axom`). Specs are verbatim: GROOVE never adds
112
+ // or removes flags (--cpu on the Spark is policy, not ours to "improve").
113
+ // opts.dataDir: adopt an existing sovereign ledger instead of minting one
114
+ // under .groove — one central ledger per user; sessions are recency
115
+ // scopes, never memory walls (SPARK_DEV_SETUP.md ruling).
116
+ async start(id = 'default', opts = {}) {
110
117
  if (!/^[a-zA-Z0-9_-]{1,40}$/.test(id)) throw new Error('invalid instance id');
111
118
  const existing = this.instances.get(id);
112
119
  if (existing && existing.status === 'running') return this.list().find((i) => i.id === id);
@@ -124,7 +131,7 @@ export class AxomServerManager {
124
131
  }
125
132
 
126
133
  const port = this._allocatePort();
127
- const dataDir = join(this.daemon.grooveDir, 'axom', 'instances', id);
134
+ const dataDir = opts.dataDir || join(this.daemon.grooveDir, 'axom', 'instances', id);
128
135
  mkdirSync(dataDir, { recursive: true });
129
136
 
130
137
  const args = [
@@ -144,9 +151,21 @@ export class AxomServerManager {
144
151
  this.instances.set(id, instance);
145
152
  this._broadcast();
146
153
 
154
+ const launch = opts.launch || { command: this._command() };
147
155
  let proc;
148
156
  try {
149
- proc = spawn(this._command(), args, { stdio: ['ignore', 'pipe', 'pipe'] });
157
+ if (launch.cwd || launch.env || /\s/.test(launch.command)) {
158
+ // Compound commands run through bash -lc — `nohup cd x && prog`-class
159
+ // failures taught us a spec is a shell line, not an argv[0].
160
+ const quotedArgs = args.map((a) => `'${String(a).replace(/'/g, `'\\''`)}'`).join(' ');
161
+ proc = spawn('bash', ['-lc', `exec ${launch.command} ${quotedArgs}`], {
162
+ stdio: ['ignore', 'pipe', 'pipe'],
163
+ cwd: launch.cwd || undefined,
164
+ env: launch.env ? { ...process.env, ...launch.env } : process.env,
165
+ });
166
+ } else {
167
+ proc = spawn(launch.command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
168
+ }
150
169
  } catch (err) {
151
170
  instance.status = 'error';
152
171
  instance.error = err.message;
@@ -159,7 +178,7 @@ export class AxomServerManager {
159
178
  proc.on('error', (err) => {
160
179
  instance.status = 'error';
161
180
  instance.error = err.code === 'ENOENT'
162
- ? `"${this._command()}" not found — install the Axom runtime first`
181
+ ? `"${launch.command}" not found — install the Axom runtime first`
163
182
  : err.message;
164
183
  this._broadcast();
165
184
  });
@@ -58,6 +58,7 @@ import { AxomConnector } from './axom-connector.js';
58
58
  import { AxomServerManager } from './axom-server.js';
59
59
  import { AxomInstaller } from './axom-install.js';
60
60
  import { AxomRemote } from './axom-remote.js';
61
+ import { AxomRuntimes } from './axom-runtimes.js';
61
62
  import { setProviderPaths } from './providers/index.js';
62
63
 
63
64
  const DEFAULT_PORT = 31415;
@@ -175,6 +176,7 @@ export class Daemon {
175
176
  this.axomServer = new AxomServerManager(this);
176
177
  this.axomInstaller = new AxomInstaller(this);
177
178
  this.axomRemote = new AxomRemote(this);
179
+ this.axomRuntimes = new AxomRuntimes(this);
178
180
  this.trajectoryCapture = null;
179
181
 
180
182
  // Hook teams.delete to clean up agent-loop session files
@@ -642,7 +644,7 @@ export class Daemon {
642
644
  this.orchestrator.start();
643
645
  this.timeline.start();
644
646
  this.gateways.start();
645
- this.axom.start();
647
+ this.axomRuntimes.start();
646
648
  this.federation.initialize();
647
649
  this._startGarbageCollector();
648
650
 
@@ -122,6 +122,79 @@ export function registerAxomRoutes(app, daemon) {
122
122
  }
123
123
  });
124
124
 
125
+ // ── Runtimes — the one entity (plans/axom-runtime-flow-redesign.md) ─────
126
+ // The GUI reasons about runtimes only; endpoints/instances/ssh are backends.
127
+
128
+ app.get('/api/axom/runtimes', async (req, res) => {
129
+ res.json(await daemon.axomRuntimes.status());
130
+ });
131
+
132
+ app.post('/api/axom/runtimes', (req, res) => {
133
+ try {
134
+ const rt = daemon.axomRuntimes.add(req.body);
135
+ if (req.body?.activate) daemon.axomRuntimes.activate(rt.id);
136
+ daemon.audit.log('axom.runtime.add', { id: rt.id, control: rt.control });
137
+ res.json(rt);
138
+ } catch (err) {
139
+ res.status(400).json({ error: err.message });
140
+ }
141
+ });
142
+
143
+ app.patch('/api/axom/runtimes/:id', (req, res) => {
144
+ try {
145
+ res.json(daemon.axomRuntimes.update(req.params.id, req.body || {}));
146
+ } catch (err) {
147
+ res.status(400).json({ error: err.message });
148
+ }
149
+ });
150
+
151
+ app.delete('/api/axom/runtimes/:id', (req, res) => {
152
+ try {
153
+ daemon.axomRuntimes.remove(req.params.id);
154
+ daemon.audit.log('axom.runtime.remove', { id: req.params.id });
155
+ res.json({ ok: true });
156
+ } catch (err) {
157
+ res.status(400).json({ error: err.message });
158
+ }
159
+ });
160
+
161
+ app.post('/api/axom/runtimes/:id/activate', (req, res) => {
162
+ try {
163
+ daemon.axomRuntimes.activate(req.params.id);
164
+ res.json({ ok: true, activeRuntimeId: req.params.id });
165
+ } catch (err) {
166
+ res.status(400).json({ error: err.message });
167
+ }
168
+ });
169
+
170
+ app.post('/api/axom/runtimes/:id/start', async (req, res) => {
171
+ try {
172
+ const result = await daemon.axomRuntimes.startRuntime(req.params.id);
173
+ daemon.audit.log('axom.runtime.start', { id: req.params.id });
174
+ res.json(result);
175
+ } catch (err) {
176
+ res.status(502).json({ error: err.message });
177
+ }
178
+ });
179
+
180
+ app.post('/api/axom/runtimes/:id/stop', async (req, res) => {
181
+ try {
182
+ const result = await daemon.axomRuntimes.stopRuntime(req.params.id, { force: !!req.body?.force });
183
+ daemon.audit.log('axom.runtime.stop', { id: req.params.id });
184
+ res.json(result);
185
+ } catch (err) {
186
+ res.status(502).json({ error: err.message });
187
+ }
188
+ });
189
+
190
+ app.post('/api/axom/runtimes/:id/heal', async (req, res) => {
191
+ try {
192
+ res.json(await daemon.axomRuntimes.heal(req.params.id));
193
+ } catch (err) {
194
+ res.status(502).json({ error: err.message });
195
+ }
196
+ });
197
+
125
198
  // ── Remote runtime control over SSH (manual only, never automatic) ──────
126
199
 
127
200
  app.get('/api/axom/remote', async (req, res) => {
@@ -20,6 +20,17 @@ export function registerWatchRoutes(app, daemon) {
20
20
  if (!who) return res.status(404).json({ error: `Unknown agent: ${agent}` });
21
21
 
22
22
  const watch = daemon.watcher.create(who.id, { command, until, label, timeoutMs, intervalMs });
23
+ if (watch.reattached) {
24
+ return res.json({
25
+ ok: true,
26
+ watchId: watch.id,
27
+ reattached: true,
28
+ message: `That command is ALREADY RUNNING under watch ${watch.id} ("${watch.label}") — `
29
+ + 'this request re-attached to it instead of starting a second copy. '
30
+ + 'Do NOT launch it again by hand. You will be resumed with the result when it finishes; '
31
+ + 'you can end your turn now.',
32
+ });
33
+ }
23
34
  res.json({
24
35
  ok: true,
25
36
  watchId: watch.id,
@@ -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
  }