groove-dev 0.27.206 → 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.
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/axom-connector.js +50 -2
- package/node_modules/@groove-dev/daemon/src/axom-remote.js +16 -14
- package/node_modules/@groove-dev/daemon/src/axom-runtimes.js +301 -0
- package/node_modules/@groove-dev/daemon/src/axom-server.js +23 -4
- package/node_modules/@groove-dev/daemon/src/chatstore.js +112 -21
- package/node_modules/@groove-dev/daemon/src/index.js +16 -1
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +4 -0
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +73 -0
- package/node_modules/@groove-dev/daemon/src/routes/chat-history.js +6 -3
- package/node_modules/@groove-dev/daemon/src/routes/watch.js +11 -0
- package/node_modules/@groove-dev/daemon/src/watcher.js +55 -2
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +44 -1
- package/node_modules/@groove-dev/daemon/test/axom-runtimes.test.js +198 -0
- package/node_modules/@groove-dev/daemon/test/chatstore.test.js +111 -19
- package/node_modules/@groove-dev/daemon/test/watcher.test.js +68 -2
- package/node_modules/@groove-dev/gui/dist/assets/{index-BYQ4qIHh.js → index-3Lzv2-To.js} +235 -235
- package/node_modules/@groove-dev/gui/dist/assets/index-Bh_HF8ed.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/axom-connector.js +50 -2
- package/packages/daemon/src/axom-remote.js +16 -14
- package/packages/daemon/src/axom-runtimes.js +301 -0
- package/packages/daemon/src/axom-server.js +23 -4
- package/packages/daemon/src/chatstore.js +112 -21
- package/packages/daemon/src/index.js +16 -1
- package/packages/daemon/src/routes/agents.js +4 -0
- package/packages/daemon/src/routes/axom.js +73 -0
- package/packages/daemon/src/routes/chat-history.js +6 -3
- package/packages/daemon/src/routes/watch.js +11 -0
- package/packages/daemon/src/watcher.js +55 -2
- package/packages/gui/dist/assets/{index-BYQ4qIHh.js → index-3Lzv2-To.js} +235 -235
- package/packages/gui/dist/assets/index-Bh_HF8ed.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-DG6yq4dB.css +0 -1
- package/packages/gui/dist/assets/index-DG6yq4dB.css +0 -1
|
@@ -9,14 +9,21 @@ const MAX_PER_AGENT = 200;
|
|
|
9
9
|
const SAVE_DEBOUNCE_MS = 1500;
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Server-side chat history.
|
|
12
|
+
* Server-side chat history, keyed by AGENT NAME.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* Two hard-won identity lessons live here:
|
|
15
|
+
*
|
|
16
|
+
* 1. localStorage is per-origin (scheme://host:PORT), and a tunnelled GUI's
|
|
17
|
+
* port changes across reconnects — so history kept only in the browser gets
|
|
18
|
+
* stranded on dead origins. Hence a server-side store at all.
|
|
19
|
+
*
|
|
20
|
+
* 2. Agent IDs are NOT stable: every rotation/resume mints a new id. A store
|
|
21
|
+
* keyed by id fragments on every rotation — the full history stays under
|
|
22
|
+
* the dead id while the new id starts near-empty, and a reconnect then
|
|
23
|
+
* "restores" that near-empty stub over the user's real history. The agent
|
|
24
|
+
* NAME survives rotation (the Watch system keys by name for the same
|
|
25
|
+
* reason), so name is the identity here. IDs are accepted at the API edge
|
|
26
|
+
* and resolved immediately.
|
|
20
27
|
*/
|
|
21
28
|
export class ChatStore {
|
|
22
29
|
constructor(daemon) {
|
|
@@ -36,6 +43,50 @@ export class ChatStore {
|
|
|
36
43
|
return {};
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
// Resolve an id-or-name to the stable storage key (the agent's name).
|
|
47
|
+
// Unknown refs are used verbatim so nothing is ever dropped — an orphaned
|
|
48
|
+
// id key re-merges at boot once its agent is known, or stays parked.
|
|
49
|
+
_keyFor(ref) {
|
|
50
|
+
if (!ref) return null;
|
|
51
|
+
const byId = this.daemon.registry?.get?.(ref);
|
|
52
|
+
if (byId?.name) return byId.name;
|
|
53
|
+
return String(ref);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Fold any id-keyed entries into their agent's name key. Runs at boot (after
|
|
58
|
+
* registry restore) so stores written by the old id-keyed code reattach to
|
|
59
|
+
* the agent wherever the id is still resolvable.
|
|
60
|
+
*/
|
|
61
|
+
migrate() {
|
|
62
|
+
const agents = this.daemon.registry?.getAll?.() || [];
|
|
63
|
+
const byId = new Map(agents.map((a) => [a.id, a]));
|
|
64
|
+
let moved = 0;
|
|
65
|
+
for (const key of Object.keys(this.history)) {
|
|
66
|
+
const agent = byId.get(key);
|
|
67
|
+
if (!agent || agent.name === key) continue;
|
|
68
|
+
this.history[agent.name] = mergeMessages(this.history[agent.name], this.history[key]);
|
|
69
|
+
delete this.history[key];
|
|
70
|
+
moved += 1;
|
|
71
|
+
}
|
|
72
|
+
if (moved) this._scheduleSave();
|
|
73
|
+
return moved;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Rotation-time hook: an entry parked under a dead id (written by the old
|
|
78
|
+
* id-keyed code, or by an old GUI posting under ids) follows the agent to
|
|
79
|
+
* its new identity.
|
|
80
|
+
*/
|
|
81
|
+
remap(oldRef, newRef) {
|
|
82
|
+
const oldKey = String(oldRef);
|
|
83
|
+
const newKey = this._keyFor(newRef);
|
|
84
|
+
if (!newKey || oldKey === newKey || !this.history[oldKey]) return;
|
|
85
|
+
this.history[newKey] = mergeMessages(this.history[newKey], this.history[oldKey]);
|
|
86
|
+
delete this.history[oldKey];
|
|
87
|
+
this._scheduleSave();
|
|
88
|
+
}
|
|
89
|
+
|
|
39
90
|
_scheduleSave() {
|
|
40
91
|
if (this._saveTimer) return;
|
|
41
92
|
this._saveTimer = setTimeout(() => {
|
|
@@ -64,35 +115,59 @@ export class ChatStore {
|
|
|
64
115
|
return out;
|
|
65
116
|
}
|
|
66
117
|
|
|
67
|
-
append(
|
|
68
|
-
|
|
118
|
+
append(ref, message) {
|
|
119
|
+
const key = this._keyFor(ref);
|
|
120
|
+
if (!key) return;
|
|
69
121
|
const clean = this._clean(message);
|
|
70
122
|
if (!clean) return;
|
|
71
|
-
const arr = this.history[
|
|
123
|
+
const arr = this.history[key] || [];
|
|
72
124
|
arr.push(clean);
|
|
73
|
-
this.history[
|
|
125
|
+
this.history[key] = arr.slice(-MAX_PER_AGENT);
|
|
74
126
|
this._scheduleSave();
|
|
75
127
|
}
|
|
76
128
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Merge a batch from a client. This is a UNION by (timestamp, from, text) —
|
|
131
|
+
* never a replace — so a browser holding messages the server missed adds
|
|
132
|
+
* them, and a browser with less than the server can never truncate it.
|
|
133
|
+
* A "restore" must be incapable of destroying what it restores from.
|
|
134
|
+
*/
|
|
135
|
+
merge(ref, messages) {
|
|
136
|
+
const key = this._keyFor(ref);
|
|
137
|
+
if (!key || !Array.isArray(messages)) return;
|
|
138
|
+
const incoming = messages.map((m) => this._clean(m)).filter(Boolean);
|
|
139
|
+
this.history[key] = mergeMessages(this.history[key], incoming);
|
|
82
140
|
this._scheduleSave();
|
|
83
141
|
}
|
|
84
142
|
|
|
143
|
+
/**
|
|
144
|
+
* History for the GUI: live agents keyed by their CURRENT id (what the GUI
|
|
145
|
+
* looks up by), everything else under its stored name key so an agent
|
|
146
|
+
* respawned under the same name picks its history back up.
|
|
147
|
+
*/
|
|
148
|
+
view() {
|
|
149
|
+
const out = {};
|
|
150
|
+
const agents = this.daemon.registry?.getAll?.() || [];
|
|
151
|
+
const liveNames = new Map(agents.map((a) => [a.name, a.id]));
|
|
152
|
+
for (const [key, msgs] of Object.entries(this.history)) {
|
|
153
|
+
if (!Array.isArray(msgs) || !msgs.length) continue;
|
|
154
|
+
out[liveNames.get(key) || key] = msgs;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
85
159
|
getAll() {
|
|
86
160
|
return this.history;
|
|
87
161
|
}
|
|
88
162
|
|
|
89
|
-
get(
|
|
90
|
-
return this.history[
|
|
163
|
+
get(ref) {
|
|
164
|
+
return this.history[this._keyFor(ref)] || [];
|
|
91
165
|
}
|
|
92
166
|
|
|
93
|
-
remove(
|
|
94
|
-
|
|
95
|
-
|
|
167
|
+
remove(ref) {
|
|
168
|
+
const key = this._keyFor(ref);
|
|
169
|
+
if (key && this.history[key]) {
|
|
170
|
+
delete this.history[key];
|
|
96
171
|
this._scheduleSave();
|
|
97
172
|
}
|
|
98
173
|
}
|
|
@@ -102,3 +177,19 @@ export class ChatStore {
|
|
|
102
177
|
this._saveNow();
|
|
103
178
|
}
|
|
104
179
|
}
|
|
180
|
+
|
|
181
|
+
// Union of two message arrays, deduped on (timestamp, from, text), time-sorted,
|
|
182
|
+
// capped. Exported for tests and the migration path.
|
|
183
|
+
export function mergeMessages(a, b) {
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const m of [...(a || []), ...(b || [])]) {
|
|
187
|
+
if (!m || typeof m !== 'object') continue;
|
|
188
|
+
const sig = `${m.timestamp}:${m.from}:${typeof m.text === 'string' ? m.text.slice(0, 200) : ''}`;
|
|
189
|
+
if (seen.has(sig)) continue;
|
|
190
|
+
seen.add(sig);
|
|
191
|
+
out.push(m);
|
|
192
|
+
}
|
|
193
|
+
out.sort((x, y) => (x.timestamp || 0) - (y.timestamp || 0));
|
|
194
|
+
return out.slice(-MAX_PER_AGENT);
|
|
195
|
+
}
|
|
@@ -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
|
|
@@ -438,6 +440,12 @@ export class Daemon {
|
|
|
438
440
|
}
|
|
439
441
|
|
|
440
442
|
broadcast(message) {
|
|
443
|
+
// Every rotation/resume path announces itself here — the one chokepoint
|
|
444
|
+
// for "this agent has a new id". Chat history parked under the dead id
|
|
445
|
+
// (stale client posts, pre-migration data) follows the agent.
|
|
446
|
+
if (message.type === 'rotation:complete' && message.oldAgentId && message.agentId) {
|
|
447
|
+
try { this.chatStore.remap(message.oldAgentId, message.agentId); } catch { /* best effort */ }
|
|
448
|
+
}
|
|
441
449
|
if (!this.wss) return;
|
|
442
450
|
const payload = JSON.stringify(message);
|
|
443
451
|
for (const client of this.wss.clients) {
|
|
@@ -636,10 +644,17 @@ export class Daemon {
|
|
|
636
644
|
this.orchestrator.start();
|
|
637
645
|
this.timeline.start();
|
|
638
646
|
this.gateways.start();
|
|
639
|
-
this.
|
|
647
|
+
this.axomRuntimes.start();
|
|
640
648
|
this.federation.initialize();
|
|
641
649
|
this._startGarbageCollector();
|
|
642
650
|
|
|
651
|
+
// Fold id-keyed chat history into name keys now that the registry is
|
|
652
|
+
// restored — reattaches histories written by the old id-keyed store.
|
|
653
|
+
try {
|
|
654
|
+
const moved = this.chatStore.migrate();
|
|
655
|
+
if (moved) console.log(`[chat] migrated ${moved} id-keyed histories to agent names`);
|
|
656
|
+
} catch { /* best effort */ }
|
|
657
|
+
|
|
643
658
|
// Regenerate the on-disk registry files once on boot. They otherwise
|
|
644
659
|
// only refresh on a registry change, so a daemon upgraded with new
|
|
645
660
|
// agent-facing docs (InnerChat, Watch) would serve stale AGENTS_REGISTRY.md
|
|
@@ -89,6 +89,10 @@ export function registerAgentRoutes(app, daemon) {
|
|
|
89
89
|
// Killed/completed agents stay visible so the user can review output.
|
|
90
90
|
const purge = req.query.purge === 'true';
|
|
91
91
|
if (purge) {
|
|
92
|
+
// Clear chat history while the id still resolves to a name — the
|
|
93
|
+
// store is name-keyed, and a purged name's history must not haunt a
|
|
94
|
+
// future agent spawned under the same name.
|
|
95
|
+
try { daemon.chatStore.remove(agent.name); } catch { /* best effort */ }
|
|
92
96
|
daemon.registry.remove(req.params.id);
|
|
93
97
|
}
|
|
94
98
|
|
|
@@ -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) => {
|
|
@@ -4,7 +4,9 @@ export function registerChatHistoryRoutes(app, daemon) {
|
|
|
4
4
|
// Full history for all agents — the GUI loads this on connect so chats are
|
|
5
5
|
// present regardless of which origin/port the tunnel came up on.
|
|
6
6
|
app.get('/api/chat-history', (req, res) => {
|
|
7
|
-
|
|
7
|
+
// view() keys live agents by their CURRENT id — ids churn on rotation, so
|
|
8
|
+
// the store itself is name-keyed and this translates at the edge.
|
|
9
|
+
res.json({ history: daemon.chatStore.view() });
|
|
8
10
|
});
|
|
9
11
|
|
|
10
12
|
// Append a single message for an agent.
|
|
@@ -17,13 +19,14 @@ export function registerChatHistoryRoutes(app, daemon) {
|
|
|
17
19
|
res.json({ ok: true });
|
|
18
20
|
});
|
|
19
21
|
|
|
20
|
-
//
|
|
22
|
+
// Batch sync from a client. A UNION, deliberately not a replace — a client
|
|
23
|
+
// can add messages the server missed but can never truncate server history.
|
|
21
24
|
app.put('/api/chat-history/:agentId', (req, res) => {
|
|
22
25
|
const { messages } = req.body || {};
|
|
23
26
|
if (!Array.isArray(messages)) {
|
|
24
27
|
return res.status(400).json({ error: 'messages array required' });
|
|
25
28
|
}
|
|
26
|
-
daemon.chatStore.
|
|
29
|
+
daemon.chatStore.merge(req.params.agentId, messages);
|
|
27
30
|
res.json({ ok: true });
|
|
28
31
|
});
|
|
29
32
|
|
|
@@ -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
|
-
`)
|
|
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]
|
|
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);
|