agentctl-swarm 0.1.1
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/lib/daemon.js +336 -0
- package/lib/daemon.test.js +314 -0
- package/lib/health-monitor.js +244 -0
- package/lib/health-monitor.test.js +183 -0
- package/lib/spawner.js +230 -0
- package/lib/spawner.test.js +182 -0
- package/lib/supervisor.js +510 -0
- package/lib/supervisor.test.js +327 -0
- package/owl/behaviors/promotion.md +30 -0
- package/owl/behaviors/recovery.md +58 -0
- package/owl/behaviors/scaling.md +39 -0
- package/owl/behaviors/swarm-lifecycle.md +38 -0
- package/owl/components/daemon.md +46 -0
- package/owl/components/health-monitor.md +37 -0
- package/owl/components/spawner.md +38 -0
- package/owl/components/supervisor.md +47 -0
- package/owl/constraints.md +42 -0
- package/owl/product.md +23 -0
- package/package.json +13 -0
package/lib/daemon.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon
|
|
3
|
+
* A lightweight idle process that listens for tasks on agentchat
|
|
4
|
+
* and promotes to a full agent session when work is available.
|
|
5
|
+
* One daemon per swarm slot.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { EventEmitter } from 'events';
|
|
9
|
+
import { spawn as spawnProcess } from 'child_process';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
|
|
13
|
+
/** Daemon states */
|
|
14
|
+
export const DaemonState = {
|
|
15
|
+
IDLE: 'idle',
|
|
16
|
+
PROMOTING: 'promoting',
|
|
17
|
+
ACTIVE: 'active',
|
|
18
|
+
DEMOTING: 'demoting',
|
|
19
|
+
CRASHED: 'crashed',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {object} DaemonConfig
|
|
24
|
+
* @property {string} agentId - agentchat agent ID
|
|
25
|
+
* @property {string} name - agent name
|
|
26
|
+
* @property {string} workspace - workspace directory path
|
|
27
|
+
* @property {string} [role] - agent role (default: 'builder')
|
|
28
|
+
* @property {string[]} [channels] - agentchat channels to join (default: ['#agents'])
|
|
29
|
+
* @property {number} [heartbeatIntervalMs] - heartbeat interval (default: 30000)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export class Daemon extends EventEmitter {
|
|
33
|
+
/**
|
|
34
|
+
* @param {DaemonConfig} config
|
|
35
|
+
*/
|
|
36
|
+
constructor(config) {
|
|
37
|
+
super();
|
|
38
|
+
|
|
39
|
+
if (!config.agentId) throw new Error('daemon requires agentId');
|
|
40
|
+
if (!config.name) throw new Error('daemon requires name');
|
|
41
|
+
if (!config.workspace) throw new Error('daemon requires workspace');
|
|
42
|
+
|
|
43
|
+
this.agentId = config.agentId;
|
|
44
|
+
this.name = config.name;
|
|
45
|
+
this.workspace = config.workspace;
|
|
46
|
+
this.role = config.role || 'builder';
|
|
47
|
+
this.channels = config.channels || ['#agents'];
|
|
48
|
+
this.heartbeatIntervalMs = config.heartbeatIntervalMs || 30000;
|
|
49
|
+
|
|
50
|
+
this.state = DaemonState.IDLE;
|
|
51
|
+
this.currentTask = null;
|
|
52
|
+
this.claudeProcess = null;
|
|
53
|
+
this._heartbeatTimer = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Start the daemon — begin sending heartbeats and listening.
|
|
58
|
+
* Does NOT connect to agentchat (that's the supervisor's responsibility
|
|
59
|
+
* to wire up message routing).
|
|
60
|
+
*/
|
|
61
|
+
start() {
|
|
62
|
+
this.state = DaemonState.IDLE;
|
|
63
|
+
this._startHeartbeat();
|
|
64
|
+
this.emit('started', { agentId: this.agentId });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Stop the daemon — cleanup timers, kill active process if any.
|
|
69
|
+
*/
|
|
70
|
+
stop() {
|
|
71
|
+
this._stopHeartbeat();
|
|
72
|
+
|
|
73
|
+
if (this.claudeProcess) {
|
|
74
|
+
this.claudeProcess.kill('SIGTERM');
|
|
75
|
+
this.claudeProcess = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.state = DaemonState.IDLE;
|
|
79
|
+
this.currentTask = null;
|
|
80
|
+
this.emit('stopped', { agentId: this.agentId });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Evaluate whether a task matches this daemon's role.
|
|
85
|
+
* @param {object} task
|
|
86
|
+
* @param {string} task.role - required role
|
|
87
|
+
* @param {string} [task.component] - component name
|
|
88
|
+
* @returns {boolean}
|
|
89
|
+
*/
|
|
90
|
+
matchesRole(task) {
|
|
91
|
+
if (!task || !task.role) return this.role === 'general';
|
|
92
|
+
return task.role === this.role || this.role === 'general';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Handle an incoming message from the work channel.
|
|
97
|
+
* Evaluates task announcements and ASSIGN messages.
|
|
98
|
+
* @param {object} msg
|
|
99
|
+
*/
|
|
100
|
+
handleMessage(msg) {
|
|
101
|
+
if (this.state !== DaemonState.IDLE) return;
|
|
102
|
+
|
|
103
|
+
// Check for ASSIGN directed at this daemon
|
|
104
|
+
if (msg.type === 'ASSIGN' && msg.agentId === this.agentId) {
|
|
105
|
+
this._requestPromotion(msg.task);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check for task announcements that match our role
|
|
110
|
+
if (msg.type === 'TASK_AVAILABLE' && this.matchesRole(msg.task)) {
|
|
111
|
+
this.emit('claim', {
|
|
112
|
+
agentId: this.agentId,
|
|
113
|
+
component: msg.task.component,
|
|
114
|
+
role: this.role,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Request promotion from supervisor.
|
|
121
|
+
* Emits 'promote-request' — supervisor must call approvePromotion() or denyPromotion().
|
|
122
|
+
* @param {object} task
|
|
123
|
+
*/
|
|
124
|
+
_requestPromotion(task) {
|
|
125
|
+
this.state = DaemonState.PROMOTING;
|
|
126
|
+
this.currentTask = task;
|
|
127
|
+
|
|
128
|
+
this.emit('promote-request', {
|
|
129
|
+
agentId: this.agentId,
|
|
130
|
+
task,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Supervisor approves promotion — spawn the claude session.
|
|
136
|
+
* @param {object} task
|
|
137
|
+
* @param {string} task.prompt - the task prompt
|
|
138
|
+
* @param {string} [task.id] - task identifier
|
|
139
|
+
* @param {string} [task.component] - component being built
|
|
140
|
+
*/
|
|
141
|
+
approvePromotion(task) {
|
|
142
|
+
if (this.state !== DaemonState.PROMOTING) {
|
|
143
|
+
this.emit('error', { agentId: this.agentId, error: 'Cannot promote: not in promoting state' });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this.currentTask = task || this.currentTask;
|
|
148
|
+
|
|
149
|
+
// Write task context before starting
|
|
150
|
+
this._writeContext(`# Active Task\n\nTask: ${this.currentTask.component || 'unknown'}\nPrompt: ${this.currentTask.prompt || 'none'}\nStarted: ${new Date().toISOString()}\n`);
|
|
151
|
+
|
|
152
|
+
// Spawn claude session
|
|
153
|
+
this._spawnClaude(this.currentTask.prompt || 'Execute the assigned task.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Supervisor denies promotion — return to idle.
|
|
158
|
+
* @param {string} [reason]
|
|
159
|
+
*/
|
|
160
|
+
denyPromotion(reason) {
|
|
161
|
+
if (this.state !== DaemonState.PROMOTING) return;
|
|
162
|
+
|
|
163
|
+
this.state = DaemonState.IDLE;
|
|
164
|
+
this.currentTask = null;
|
|
165
|
+
|
|
166
|
+
this.emit('unclaim', {
|
|
167
|
+
agentId: this.agentId,
|
|
168
|
+
reason: reason || 'promotion denied',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Spawn a claude -p session in the workspace.
|
|
174
|
+
* @param {string} prompt
|
|
175
|
+
*/
|
|
176
|
+
_spawnClaude(prompt) {
|
|
177
|
+
this.state = DaemonState.ACTIVE;
|
|
178
|
+
this._stopHeartbeat(); // Active agents don't send idle heartbeats
|
|
179
|
+
|
|
180
|
+
const args = ['-p', prompt, '--cwd', this.workspace];
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
this.claudeProcess = spawnProcess('claude', args, {
|
|
184
|
+
cwd: this.workspace,
|
|
185
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
this.emit('promoted', {
|
|
189
|
+
agentId: this.agentId,
|
|
190
|
+
pid: this.claudeProcess.pid,
|
|
191
|
+
task: this.currentTask,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
let stdout = '';
|
|
195
|
+
let stderr = '';
|
|
196
|
+
|
|
197
|
+
this.claudeProcess.stdout.on('data', (data) => {
|
|
198
|
+
const chunk = data.toString();
|
|
199
|
+
stdout += chunk;
|
|
200
|
+
this.emit('output', { agentId: this.agentId, data: chunk, stream: 'stdout' });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
this.claudeProcess.stderr.on('data', (data) => {
|
|
204
|
+
const chunk = data.toString();
|
|
205
|
+
stderr += chunk;
|
|
206
|
+
this.emit('output', { agentId: this.agentId, data: chunk, stream: 'stderr' });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
this.claudeProcess.on('exit', (code, signal) => {
|
|
210
|
+
this.claudeProcess = null;
|
|
211
|
+
this._handleClaudeExit(code, signal, stdout, stderr);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
this.claudeProcess.on('error', (err) => {
|
|
215
|
+
this.claudeProcess = null;
|
|
216
|
+
this._handleClaudeError(err);
|
|
217
|
+
});
|
|
218
|
+
} catch (err) {
|
|
219
|
+
this._handleClaudeError(err);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Handle claude process exit — begin demotion.
|
|
225
|
+
*/
|
|
226
|
+
_handleClaudeExit(code, signal, stdout, stderr) {
|
|
227
|
+
this.state = DaemonState.DEMOTING;
|
|
228
|
+
|
|
229
|
+
const success = code === 0;
|
|
230
|
+
const result = {
|
|
231
|
+
agentId: this.agentId,
|
|
232
|
+
task: this.currentTask,
|
|
233
|
+
exitCode: code,
|
|
234
|
+
signal,
|
|
235
|
+
success,
|
|
236
|
+
output: stdout.slice(-2000), // Last 2000 chars
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Save context for crash recovery
|
|
240
|
+
this._writeContext(
|
|
241
|
+
`# Last Task\n\n` +
|
|
242
|
+
`Task: ${this.currentTask?.component || 'unknown'}\n` +
|
|
243
|
+
`Result: ${success ? 'SUCCESS' : 'FAIL'}\n` +
|
|
244
|
+
`Exit code: ${code}\n` +
|
|
245
|
+
`Completed: ${new Date().toISOString()}\n` +
|
|
246
|
+
`\n## Output (last 500 chars)\n\n${stdout.slice(-500)}\n`
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (success) {
|
|
250
|
+
this.emit('done', result);
|
|
251
|
+
} else {
|
|
252
|
+
this.emit('fail', result);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Complete demotion
|
|
256
|
+
this.state = DaemonState.IDLE;
|
|
257
|
+
this.currentTask = null;
|
|
258
|
+
this._startHeartbeat();
|
|
259
|
+
|
|
260
|
+
this.emit('demoted', { agentId: this.agentId });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Handle claude spawn/runtime error.
|
|
265
|
+
*/
|
|
266
|
+
_handleClaudeError(err) {
|
|
267
|
+
this.state = DaemonState.CRASHED;
|
|
268
|
+
|
|
269
|
+
this._writeContext(
|
|
270
|
+
`# Crash\n\nError: ${err.message}\nTime: ${new Date().toISOString()}\n`
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
this.emit('fail', {
|
|
274
|
+
agentId: this.agentId,
|
|
275
|
+
task: this.currentTask,
|
|
276
|
+
exitCode: null,
|
|
277
|
+
signal: null,
|
|
278
|
+
success: false,
|
|
279
|
+
error: err.message,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
this.emit('crashed', { agentId: this.agentId, error: err.message });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Write context.md to workspace.
|
|
287
|
+
* @param {string} content
|
|
288
|
+
*/
|
|
289
|
+
_writeContext(content) {
|
|
290
|
+
try {
|
|
291
|
+
fs.writeFileSync(path.join(this.workspace, 'context.md'), content);
|
|
292
|
+
} catch {
|
|
293
|
+
// Best effort — don't crash daemon on write failure
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Start periodic heartbeat.
|
|
299
|
+
*/
|
|
300
|
+
_startHeartbeat() {
|
|
301
|
+
this._stopHeartbeat();
|
|
302
|
+
this._heartbeatTimer = setInterval(() => {
|
|
303
|
+
this.emit('heartbeat', {
|
|
304
|
+
agentId: this.agentId,
|
|
305
|
+
status: this.state,
|
|
306
|
+
});
|
|
307
|
+
}, this.heartbeatIntervalMs);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Stop periodic heartbeat.
|
|
312
|
+
*/
|
|
313
|
+
_stopHeartbeat() {
|
|
314
|
+
if (this._heartbeatTimer) {
|
|
315
|
+
clearInterval(this._heartbeatTimer);
|
|
316
|
+
this._heartbeatTimer = null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Get current daemon info.
|
|
322
|
+
*/
|
|
323
|
+
info() {
|
|
324
|
+
return {
|
|
325
|
+
agentId: this.agentId,
|
|
326
|
+
name: this.name,
|
|
327
|
+
role: this.role,
|
|
328
|
+
state: this.state,
|
|
329
|
+
workspace: this.workspace,
|
|
330
|
+
channels: this.channels,
|
|
331
|
+
currentTask: this.currentTask,
|
|
332
|
+
hasClaude: !!this.claudeProcess,
|
|
333
|
+
pid: this.claudeProcess?.pid || null,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { test, describe, before, after } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import { Daemon, DaemonState } from './daemon.js';
|
|
11
|
+
|
|
12
|
+
const tmpBase = path.join(os.tmpdir(), `daemon-test-${Date.now()}`);
|
|
13
|
+
|
|
14
|
+
function makeDaemon(overrides = {}) {
|
|
15
|
+
const name = `test-daemon-${Math.random().toString(36).slice(2, 8)}`;
|
|
16
|
+
const workspace = path.join(tmpBase, name);
|
|
17
|
+
fs.mkdirSync(workspace, { recursive: true });
|
|
18
|
+
|
|
19
|
+
return new Daemon({
|
|
20
|
+
agentId: `agent-${name}`,
|
|
21
|
+
name,
|
|
22
|
+
workspace,
|
|
23
|
+
heartbeatIntervalMs: 100,
|
|
24
|
+
...overrides,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('Daemon', () => {
|
|
29
|
+
before(() => {
|
|
30
|
+
fs.mkdirSync(tmpBase, { recursive: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
after(() => {
|
|
34
|
+
fs.rmSync(tmpBase, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('constructor requires agentId, name, workspace', () => {
|
|
38
|
+
assert.throws(() => new Daemon({}), /agentId/);
|
|
39
|
+
assert.throws(() => new Daemon({ agentId: 'a' }), /name/);
|
|
40
|
+
assert.throws(() => new Daemon({ agentId: 'a', name: 'b' }), /workspace/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('starts in idle state', () => {
|
|
44
|
+
const d = makeDaemon();
|
|
45
|
+
assert.strictEqual(d.state, DaemonState.IDLE);
|
|
46
|
+
d.stop();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('start emits started event', () => {
|
|
50
|
+
const d = makeDaemon();
|
|
51
|
+
const events = [];
|
|
52
|
+
d.on('started', e => events.push(e));
|
|
53
|
+
d.start();
|
|
54
|
+
assert.strictEqual(events.length, 1);
|
|
55
|
+
assert.strictEqual(events[0].agentId, d.agentId);
|
|
56
|
+
d.stop();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('sends heartbeats when idle', async () => {
|
|
60
|
+
const d = makeDaemon({ heartbeatIntervalMs: 50 });
|
|
61
|
+
const heartbeats = [];
|
|
62
|
+
d.on('heartbeat', h => heartbeats.push(h));
|
|
63
|
+
d.start();
|
|
64
|
+
|
|
65
|
+
await new Promise(r => setTimeout(r, 180));
|
|
66
|
+
d.stop();
|
|
67
|
+
|
|
68
|
+
assert.ok(heartbeats.length >= 2, `Expected >=2 heartbeats, got ${heartbeats.length}`);
|
|
69
|
+
assert.strictEqual(heartbeats[0].status, DaemonState.IDLE);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('matchesRole for builder', () => {
|
|
73
|
+
const d = makeDaemon({ role: 'builder' });
|
|
74
|
+
assert.strictEqual(d.matchesRole({ role: 'builder' }), true);
|
|
75
|
+
assert.strictEqual(d.matchesRole({ role: 'auditor' }), false);
|
|
76
|
+
d.stop();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('matchesRole for general matches everything', () => {
|
|
80
|
+
const d = makeDaemon({ role: 'general' });
|
|
81
|
+
assert.strictEqual(d.matchesRole({ role: 'builder' }), true);
|
|
82
|
+
assert.strictEqual(d.matchesRole({ role: 'auditor' }), true);
|
|
83
|
+
assert.strictEqual(d.matchesRole({}), true);
|
|
84
|
+
d.stop();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('handleMessage ignores when not idle', () => {
|
|
88
|
+
const d = makeDaemon();
|
|
89
|
+
d.state = DaemonState.ACTIVE;
|
|
90
|
+
const events = [];
|
|
91
|
+
d.on('claim', e => events.push(e));
|
|
92
|
+
d.on('promote-request', e => events.push(e));
|
|
93
|
+
|
|
94
|
+
d.handleMessage({ type: 'TASK_AVAILABLE', task: { role: 'builder' } });
|
|
95
|
+
d.handleMessage({ type: 'ASSIGN', agentId: d.agentId, task: {} });
|
|
96
|
+
|
|
97
|
+
assert.strictEqual(events.length, 0);
|
|
98
|
+
d.stop();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('handleMessage emits claim for matching task', () => {
|
|
102
|
+
const d = makeDaemon({ role: 'builder' });
|
|
103
|
+
d.start();
|
|
104
|
+
const claims = [];
|
|
105
|
+
d.on('claim', c => claims.push(c));
|
|
106
|
+
|
|
107
|
+
d.handleMessage({
|
|
108
|
+
type: 'TASK_AVAILABLE',
|
|
109
|
+
task: { role: 'builder', component: 'spawner' },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
assert.strictEqual(claims.length, 1);
|
|
113
|
+
assert.strictEqual(claims[0].component, 'spawner');
|
|
114
|
+
d.stop();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('handleMessage does not claim non-matching role', () => {
|
|
118
|
+
const d = makeDaemon({ role: 'builder' });
|
|
119
|
+
d.start();
|
|
120
|
+
const claims = [];
|
|
121
|
+
d.on('claim', c => claims.push(c));
|
|
122
|
+
|
|
123
|
+
d.handleMessage({
|
|
124
|
+
type: 'TASK_AVAILABLE',
|
|
125
|
+
task: { role: 'auditor', component: 'qa' },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
assert.strictEqual(claims.length, 0);
|
|
129
|
+
d.stop();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('ASSIGN triggers promote-request', () => {
|
|
133
|
+
const d = makeDaemon();
|
|
134
|
+
d.start();
|
|
135
|
+
const requests = [];
|
|
136
|
+
d.on('promote-request', r => requests.push(r));
|
|
137
|
+
|
|
138
|
+
d.handleMessage({
|
|
139
|
+
type: 'ASSIGN',
|
|
140
|
+
agentId: d.agentId,
|
|
141
|
+
task: { component: 'spawner', prompt: 'build it' },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
assert.strictEqual(requests.length, 1);
|
|
145
|
+
assert.strictEqual(d.state, DaemonState.PROMOTING);
|
|
146
|
+
d.stop();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('denyPromotion returns to idle', () => {
|
|
150
|
+
const d = makeDaemon();
|
|
151
|
+
d.start();
|
|
152
|
+
const unclaims = [];
|
|
153
|
+
d.on('unclaim', u => unclaims.push(u));
|
|
154
|
+
|
|
155
|
+
d.handleMessage({
|
|
156
|
+
type: 'ASSIGN',
|
|
157
|
+
agentId: d.agentId,
|
|
158
|
+
task: { component: 'spawner' },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
assert.strictEqual(d.state, DaemonState.PROMOTING);
|
|
162
|
+
d.denyPromotion('quota exceeded');
|
|
163
|
+
|
|
164
|
+
assert.strictEqual(d.state, DaemonState.IDLE);
|
|
165
|
+
assert.strictEqual(d.currentTask, null);
|
|
166
|
+
assert.strictEqual(unclaims.length, 1);
|
|
167
|
+
assert.ok(unclaims[0].reason.includes('quota'));
|
|
168
|
+
d.stop();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('approvePromotion in wrong state emits error', () => {
|
|
172
|
+
const d = makeDaemon();
|
|
173
|
+
d.start();
|
|
174
|
+
const errors = [];
|
|
175
|
+
d.on('error', e => errors.push(e));
|
|
176
|
+
|
|
177
|
+
d.approvePromotion({ prompt: 'test' });
|
|
178
|
+
assert.strictEqual(errors.length, 1);
|
|
179
|
+
assert.ok(errors[0].error.includes('not in promoting'));
|
|
180
|
+
d.stop();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('approvePromotion writes context.md', () => {
|
|
184
|
+
const d = makeDaemon();
|
|
185
|
+
d.start();
|
|
186
|
+
|
|
187
|
+
// Get into promoting state
|
|
188
|
+
d.handleMessage({
|
|
189
|
+
type: 'ASSIGN',
|
|
190
|
+
agentId: d.agentId,
|
|
191
|
+
task: { component: 'test-comp', prompt: 'build the test component' },
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Override _spawnClaude to avoid actually running claude
|
|
195
|
+
d._spawnClaude = () => {
|
|
196
|
+
d.state = DaemonState.ACTIVE;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
d.approvePromotion({ component: 'test-comp', prompt: 'build the test component' });
|
|
200
|
+
|
|
201
|
+
const contextPath = path.join(d.workspace, 'context.md');
|
|
202
|
+
assert.ok(fs.existsSync(contextPath));
|
|
203
|
+
const content = fs.readFileSync(contextPath, 'utf8');
|
|
204
|
+
assert.ok(content.includes('test-comp'));
|
|
205
|
+
d.stop();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('demotion writes context and returns to idle', () => {
|
|
209
|
+
const d = makeDaemon();
|
|
210
|
+
d.start();
|
|
211
|
+
const doneEvents = [];
|
|
212
|
+
const demotedEvents = [];
|
|
213
|
+
d.on('done', e => doneEvents.push(e));
|
|
214
|
+
d.on('demoted', e => demotedEvents.push(e));
|
|
215
|
+
|
|
216
|
+
// Simulate full lifecycle
|
|
217
|
+
d.state = DaemonState.ACTIVE;
|
|
218
|
+
d.currentTask = { component: 'test', prompt: 'test task' };
|
|
219
|
+
|
|
220
|
+
// Simulate claude exit
|
|
221
|
+
d._handleClaudeExit(0, null, 'build complete', '');
|
|
222
|
+
|
|
223
|
+
assert.strictEqual(d.state, DaemonState.IDLE);
|
|
224
|
+
assert.strictEqual(d.currentTask, null);
|
|
225
|
+
assert.strictEqual(doneEvents.length, 1);
|
|
226
|
+
assert.strictEqual(doneEvents[0].success, true);
|
|
227
|
+
assert.strictEqual(demotedEvents.length, 1);
|
|
228
|
+
|
|
229
|
+
// context.md written
|
|
230
|
+
const content = fs.readFileSync(path.join(d.workspace, 'context.md'), 'utf8');
|
|
231
|
+
assert.ok(content.includes('SUCCESS'));
|
|
232
|
+
d.stop();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('failed claude exit emits fail', () => {
|
|
236
|
+
const d = makeDaemon();
|
|
237
|
+
d.start();
|
|
238
|
+
const failEvents = [];
|
|
239
|
+
d.on('fail', e => failEvents.push(e));
|
|
240
|
+
|
|
241
|
+
d.state = DaemonState.ACTIVE;
|
|
242
|
+
d.currentTask = { component: 'broken', prompt: 'fail' };
|
|
243
|
+
|
|
244
|
+
d._handleClaudeExit(1, null, '', 'error output');
|
|
245
|
+
|
|
246
|
+
assert.strictEqual(d.state, DaemonState.IDLE);
|
|
247
|
+
assert.strictEqual(failEvents.length, 1);
|
|
248
|
+
assert.strictEqual(failEvents[0].success, false);
|
|
249
|
+
assert.strictEqual(failEvents[0].exitCode, 1);
|
|
250
|
+
d.stop();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('claude spawn error sets crashed state', () => {
|
|
254
|
+
const d = makeDaemon();
|
|
255
|
+
d.start();
|
|
256
|
+
const crashEvents = [];
|
|
257
|
+
d.on('crashed', e => crashEvents.push(e));
|
|
258
|
+
|
|
259
|
+
d.state = DaemonState.ACTIVE;
|
|
260
|
+
d.currentTask = { component: 'broken' };
|
|
261
|
+
|
|
262
|
+
d._handleClaudeError(new Error('command not found'));
|
|
263
|
+
|
|
264
|
+
assert.strictEqual(d.state, DaemonState.CRASHED);
|
|
265
|
+
assert.strictEqual(crashEvents.length, 1);
|
|
266
|
+
assert.ok(crashEvents[0].error.includes('command not found'));
|
|
267
|
+
|
|
268
|
+
// context.md has crash info
|
|
269
|
+
const content = fs.readFileSync(path.join(d.workspace, 'context.md'), 'utf8');
|
|
270
|
+
assert.ok(content.includes('Crash'));
|
|
271
|
+
d.stop();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('idle daemon only sends heartbeats', () => {
|
|
275
|
+
const d = makeDaemon();
|
|
276
|
+
// Verify no claim/output/done/fail emissions from an idle daemon
|
|
277
|
+
// that receives non-matching messages
|
|
278
|
+
const badEvents = [];
|
|
279
|
+
d.on('output', e => badEvents.push(e));
|
|
280
|
+
d.on('done', e => badEvents.push(e));
|
|
281
|
+
d.on('fail', e => badEvents.push(e));
|
|
282
|
+
d.on('promoted', e => badEvents.push(e));
|
|
283
|
+
|
|
284
|
+
d.start();
|
|
285
|
+
d.handleMessage({ type: 'MSG', content: 'hello' });
|
|
286
|
+
d.handleMessage({ type: 'UNKNOWN', data: 'noise' });
|
|
287
|
+
|
|
288
|
+
assert.strictEqual(badEvents.length, 0);
|
|
289
|
+
d.stop();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('info returns current state', () => {
|
|
293
|
+
const d = makeDaemon({ role: 'auditor' });
|
|
294
|
+
d.start();
|
|
295
|
+
|
|
296
|
+
const info = d.info();
|
|
297
|
+
assert.strictEqual(info.agentId, d.agentId);
|
|
298
|
+
assert.strictEqual(info.role, 'auditor');
|
|
299
|
+
assert.strictEqual(info.state, DaemonState.IDLE);
|
|
300
|
+
assert.strictEqual(info.currentTask, null);
|
|
301
|
+
assert.strictEqual(info.hasClaude, false);
|
|
302
|
+
d.stop();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('stop cleans up timers', () => {
|
|
306
|
+
const d = makeDaemon({ heartbeatIntervalMs: 50 });
|
|
307
|
+
d.start();
|
|
308
|
+
assert.ok(d._heartbeatTimer);
|
|
309
|
+
|
|
310
|
+
d.stop();
|
|
311
|
+
assert.strictEqual(d._heartbeatTimer, null);
|
|
312
|
+
assert.strictEqual(d.state, DaemonState.IDLE);
|
|
313
|
+
});
|
|
314
|
+
});
|