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 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
+ });