agent-pool-mcp 1.5.0 → 1.6.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-pool-mcp",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "description": "MCP Server for multi-agent task delegation and orchestration via Gemini CLI",
6
6
  "main": "index.js",
@@ -11,10 +11,14 @@
11
11
  * @module agent-pool/scheduler/daemon
12
12
  */
13
13
 
14
- import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from 'node:fs';
15
15
  import { spawn } from 'node:child_process';
16
16
  import { join, dirname } from 'node:path';
17
17
  import { matchesCron } from './cron.js';
18
+ import { getGroup } from '../tools/groups.js';
19
+ import { getRunner } from '../runner/config.js';
20
+ import { buildSshSpawn } from '../runner/ssh.js';
21
+ import { killGroup } from '../runner/process-manager.js';
18
22
 
19
23
  const POLL_INTERVAL_MS = 30_000; // Check schedules every 30 seconds
20
24
  const PID_FILE = '.agents/scheduler.pid';
@@ -161,60 +165,119 @@ function executeSchedule(schedule) {
161
165
 
162
166
  // ─── Pipeline tick ──────────────────────────────────────────
163
167
 
164
- import { readdirSync } from 'node:fs';
165
-
166
168
  const PIPELINES_DIR = '.agents/pipelines';
167
169
  const RUNS_DIR = '.agents/runs';
168
170
 
169
171
  /**
170
- * Spawn a Gemini CLI agent for a pipeline step.
172
+ * Spawn Gemini CLI agent(s) for a pipeline step.
171
173
  * @param {object} stepDef - Step definition from pipeline
172
174
  * @param {object} run - Current run state
173
175
  * @param {string} runId
174
176
  * @param {string} [bounceReason] - If bouncing back, the reason
175
- * @returns {number} child PID
177
+ * @returns {number[]} Array of child PIDs
176
178
  */
177
179
  function spawnStep(stepDef, run, runId, bounceReason) {
178
- let prompt = stepDef.prompt;
179
- if (bounceReason) {
180
- prompt = `${stepDef.prompt}\n\n⚠️ BOUNCE BACK: предыдущая попытка была отклонена следующим шагом.\nПричина: ${bounceReason}\nДополни и улучши результат.`;
180
+ const count = stepDef.count || 1;
181
+ const pids = [];
182
+
183
+ // Resolve group
184
+ let groupConfig = {};
185
+ if (stepDef.group) {
186
+ groupConfig = getGroup(run.cwd || cwd, stepDef.group) || {};
181
187
  }
182
188
 
183
- // Inject pipeline context
184
- prompt = `[Pipeline: ${run.pipelineName}, Step: ${stepDef.name}, Run: ${runId}]\n\nTask:\n${prompt}\n\nWhen finished, call signal_step_complete with step_name "${stepDef.name}" and run_id "${runId}".`;
189
+ const skill = stepDef.skill || groupConfig.skill;
190
+ const policy = groupConfig.policy; // currently policy only from group
191
+ const runnerId = groupConfig.runner;
192
+ const runner = runnerId ? getRunner(runnerId) : { type: 'local' };
193
+ const isRemote = runner && runner.type === 'ssh';
185
194
 
186
- const args = [
187
- '-p', prompt,
188
- '--output-format', 'stream-json',
189
- '--approval-mode', stepDef.approvalMode || 'yolo',
190
- ];
195
+ for (let i = 0; i < count; i++) {
196
+ let prompt = stepDef.prompt;
197
+ if (bounceReason) {
198
+ prompt = `${stepDef.prompt}\n\n⚠️ BOUNCE BACK: предыдущая попытка была отклонена следующим шагом.\nПричина: ${bounceReason}\nДополни и улучши результат.`;
199
+ }
191
200
 
192
- const child = spawn('gemini', args, {
193
- cwd: run.cwd || cwd,
194
- env: { ...process.env, TERM: 'dumb', CI: '1' },
195
- stdio: ['pipe', 'pipe', 'pipe'],
196
- detached: true,
197
- });
201
+ if (count > 1) {
202
+ prompt = `[Agent ${i + 1}/${count}]\n\n${prompt}`;
203
+ }
198
204
 
199
- child.on('close', (code) => {
200
- // Update step exit code in run state
201
- try {
202
- const currentRun = JSON.parse(readFileSync(join(cwd, RUNS_DIR, `${runId}.json`), 'utf-8'));
203
- if (currentRun.steps[stepDef.name]) {
204
- currentRun.steps[stepDef.name].exitCode = code;
205
+ // Inject pipeline context
206
+ prompt = `[Pipeline: ${run.pipelineName}, Step: ${stepDef.name}, Run: ${runId}]\n\nTask:\n${prompt}\n\nWhen finished, call signal_step_complete with step_name "${stepDef.name}" and run_id "${runId}".`;
207
+
208
+ const args = [
209
+ '-p', prompt,
210
+ '--output-format', 'stream-json',
211
+ '--approval-mode', stepDef.approvalMode || 'yolo',
212
+ ];
213
+
214
+ if (skill) {
215
+ // Skills can be active via prompt injection, as we do for scheduled tasks
216
+ args[1] = `Activate skill "${skill}" first.\n\n${args[1]}`;
217
+ }
218
+ if (policy) {
219
+ args.push('--policy', policy);
220
+ }
221
+ if (groupConfig.include_dirs?.length > 0) {
222
+ for (const dir of groupConfig.include_dirs) {
223
+ args.push('--include-directories', dir);
205
224
  }
206
- writeFileSync(join(cwd, RUNS_DIR, `${runId}.json`), JSON.stringify(currentRun, null, 2));
207
- } catch { /* ignore */ }
208
- console.error(`[pipeline] Step "${stepDef.name}" exited (code: ${code}, run: ${runId})`);
209
- });
225
+ }
210
226
 
211
- child.stdin.end();
212
- child.unref();
227
+ let spawnCmd, spawnArgs, spawnOpts;
228
+ if (isRemote) {
229
+ const ssh = buildSshSpawn(runner, args, run.cwd || cwd);
230
+ spawnCmd = ssh.command;
231
+ spawnArgs = ssh.args;
232
+ spawnOpts = { stdio: ['pipe', 'pipe', 'pipe'], detached: true };
233
+ } else {
234
+ spawnCmd = 'gemini';
235
+ spawnArgs = args;
236
+ const currentDepth = parseInt(process.env.AGENT_POOL_DEPTH ?? '0');
237
+ spawnOpts = {
238
+ cwd: run.cwd || cwd,
239
+ env: {
240
+ ...process.env,
241
+ TERM: 'dumb',
242
+ CI: '1',
243
+ AGENT_POOL_DEPTH: String(currentDepth + 1)
244
+ },
245
+ stdio: ['pipe', 'pipe', 'pipe'],
246
+ detached: true,
247
+ };
248
+ if (count > 1) spawnOpts.env.AGENT_INDEX = String(i);
249
+ }
250
+
251
+ const child = spawn(spawnCmd, spawnArgs, spawnOpts);
252
+
253
+ child.on('close', (code) => {
254
+ // Update step exit code in run state
255
+ try {
256
+ const currentRun = JSON.parse(readFileSync(join(cwd, RUNS_DIR, `${runId}.json`), 'utf-8'));
257
+ if (currentRun.steps[stepDef.name]) {
258
+ // If any child fails, set exit code to non-zero
259
+ if (code !== 0) {
260
+ currentRun.steps[stepDef.name].exitCode = code;
261
+ } else if (currentRun.steps[stepDef.name].exitCode === null) {
262
+ currentRun.steps[stepDef.name].exitCode = 0;
263
+ }
264
+ }
265
+ writeFileSync(join(cwd, RUNS_DIR, `${runId}.json`), JSON.stringify(currentRun, null, 2));
266
+ } catch { /* ignore */ }
267
+ console.error(`[pipeline] Step "${stepDef.name}" [pid ${child.pid}] exited (code: ${code}, run: ${runId})`);
268
+ });
269
+
270
+ child.stdin.end();
271
+ child.unref();
213
272
 
214
- console.error(`[pipeline] Started step "${stepDef.name}" → pid ${child.pid} (run: ${runId})`);
215
- return child.pid;
273
+ console.error(`[pipeline] Started step "${stepDef.name}" → pid ${child.pid} (run: ${runId})`);
274
+ pids.push(child.pid);
275
+ }
276
+
277
+ return pids;
216
278
  }
217
279
 
280
+
218
281
  /**
219
282
  * Check if a process is alive.
220
283
  * @param {number} pid
@@ -261,33 +324,67 @@ function tickPipelines() {
261
324
  if (step.status === 'bounce_pending') {
262
325
  step.status = 'running';
263
326
  step.startedAt = new Date().toISOString();
264
- step.pid = spawnStep(stepDef, run, runId, step.lastBounceReason);
327
+ const pids = spawnStep(stepDef, run, runId, step.lastBounceReason);
328
+ step.pids = pids;
329
+ if (pids.length > 0) step.pid = pids[0];
265
330
  modified = true;
266
331
  continue;
267
332
  }
268
333
 
269
334
  // ── Handle running steps: check if process died ──
270
- if (step.status === 'running' && step.pid) {
271
- if (!isAlive(step.pid)) {
272
- // Process is dead — did agent signal?
273
- if (!step.signaled) {
274
- // Auto-fallback: check exit code
275
- if (step.exitCode === 0 || step.exitCode === null) {
276
- // Treat as success (agent forgot to signal)
277
- step.status = 'success';
278
- step.completedAt = new Date().toISOString();
279
- console.error(`[pipeline] Step "${stepDef.name}" auto-completed (pid dead, exit: ${step.exitCode})`);
280
- } else {
281
- // Failed
282
- step.status = 'failed';
283
- step.completedAt = new Date().toISOString();
284
- console.error(`[pipeline] Step "${stepDef.name}" failed (exit: ${step.exitCode})`);
285
- if (pipeline.onError === 'stop') {
286
- run.status = 'failed';
287
- run.completedAt = new Date().toISOString();
288
- }
335
+ if (step.status === 'running') {
336
+ const pids = step.pids?.length > 0 ? step.pids : (step.pid ? [step.pid] : []);
337
+ if (pids.length === 0) continue;
338
+
339
+ let livingPids = 0;
340
+ for (const pid of pids) if (isAlive(pid)) livingPids++;
341
+
342
+ const isParallel = pids.length > 1;
343
+
344
+ if (isParallel) {
345
+ // Parallel semantics: rely entirely on exit codes
346
+ if (step.exitCode !== null && step.exitCode !== 0) {
347
+ // Fail fast: kill siblings
348
+ for (const pid of pids) if (isAlive(pid)) killGroup(pid);
349
+ step.status = 'failed';
350
+ step.completedAt = new Date().toISOString();
351
+ console.error(`[pipeline] Step "${stepDef.name}" parallel failed (exit: ${step.exitCode})`);
352
+ if (pipeline.onError === 'stop') {
353
+ run.status = 'failed';
354
+ run.completedAt = new Date().toISOString();
289
355
  }
290
356
  modified = true;
357
+ } else if (livingPids === 0) {
358
+ // All dead and no errors
359
+ step.status = 'success';
360
+ step.completedAt = new Date().toISOString();
361
+ console.error(`[pipeline] Step "${stepDef.name}" parallel completed successfully`);
362
+ modified = true;
363
+ }
364
+ } else {
365
+ // Sequential semantics (count 1)
366
+ const pid = pids[0];
367
+ if (!isAlive(pid)) {
368
+ // Process is dead — did agent signal?
369
+ if (!step.signaled) {
370
+ // Auto-fallback: check exit code
371
+ if (step.exitCode === 0 || step.exitCode === null) {
372
+ // Treat as success (agent forgot to signal)
373
+ step.status = 'success';
374
+ step.completedAt = new Date().toISOString();
375
+ console.error(`[pipeline] Step "${stepDef.name}" auto-completed (pid dead, exit: ${step.exitCode})`);
376
+ } else {
377
+ // Failed
378
+ step.status = 'failed';
379
+ step.completedAt = new Date().toISOString();
380
+ console.error(`[pipeline] Step "${stepDef.name}" failed (exit: ${step.exitCode})`);
381
+ if (pipeline.onError === 'stop') {
382
+ run.status = 'failed';
383
+ run.completedAt = new Date().toISOString();
384
+ }
385
+ }
386
+ modified = true;
387
+ }
291
388
  }
292
389
  }
293
390
  continue;
@@ -324,7 +421,9 @@ function tickPipelines() {
324
421
  if (shouldStart && run.status === 'running') {
325
422
  step.status = 'running';
326
423
  step.startedAt = new Date().toISOString();
327
- step.pid = spawnStep(stepDef, run, runId);
424
+ const pids = spawnStep(stepDef, run, runId);
425
+ step.pids = pids;
426
+ if (pids.length > 0) step.pid = pids[0];
328
427
  modified = true;
329
428
  }
330
429
  }
@@ -335,7 +434,9 @@ function tickPipelines() {
335
434
  if (depStepName && run.steps[depStepName]?.status === 'success') {
336
435
  step.status = 'running';
337
436
  step.startedAt = new Date().toISOString();
338
- step.pid = spawnStep(stepDef, run, runId);
437
+ const pids = spawnStep(stepDef, run, runId);
438
+ step.pids = pids;
439
+ if (pids.length > 0) step.pid = pids[0];
339
440
  modified = true;
340
441
  }
341
442
  }
@@ -11,6 +11,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlink
11
11
  import { join, dirname } from 'node:path';
12
12
  import { randomUUID } from 'node:crypto';
13
13
  import { ensureDaemon } from './scheduler.js';
14
+ import { killGroup } from '../runner/process-manager.js';
14
15
 
15
16
  const PIPELINES_DIR = '.agents/pipelines';
16
17
  const RUNS_DIR = '.agents/runs';
@@ -69,6 +70,8 @@ export function createPipeline(cwd, { name, steps, onError }) {
69
70
  name: s.name,
70
71
  prompt: s.prompt,
71
72
  skill: s.skill || null,
73
+ group: s.group || null,
74
+ count: s.count ? parseInt(s.count, 10) : 1,
72
75
  approvalMode: s.approval_mode || 'yolo',
73
76
  timeout: s.timeout || 600,
74
77
  maxBounces: s.maxBounces ?? s.max_bounces ?? 2,
@@ -134,7 +137,8 @@ export function runPipeline(cwd, pipelineId) {
134
137
  for (const step of pipeline.steps) {
135
138
  steps[step.name] = {
136
139
  status: 'pending',
137
- pid: null,
140
+ pid: null, // Legacy / single pid
141
+ pids: [], // Array for parallel execution
138
142
  exitCode: null,
139
143
  signaled: false,
140
144
  bounces: 0,
@@ -219,8 +223,13 @@ export function cancelRun(cwd, runId) {
219
223
 
220
224
  // Kill any running step
221
225
  for (const [name, step] of Object.entries(run.steps)) {
222
- if (step.status === 'running' && step.pid) {
223
- try { process.kill(step.pid, 'SIGTERM'); } catch { /* already dead */ }
226
+ if (step.status === 'running') {
227
+ const pidsToKill = [...(step.pids || [])];
228
+ if (step.pid && !pidsToKill.includes(step.pid)) pidsToKill.push(step.pid);
229
+
230
+ for (const pid of pidsToKill) {
231
+ killGroup(pid);
232
+ }
224
233
  step.status = 'cancelled';
225
234
  }
226
235
  if (step.status === 'pending') {
@@ -332,7 +341,16 @@ export function bounceBack(cwd, targetStepName, reason, runId) {
332
341
  targetStep.status = 'bounce_pending';
333
342
  targetStep.bounces += 1;
334
343
  targetStep.lastBounceReason = reason;
344
+
345
+ // Fail-fast: kill any remaining running agents for this step
346
+ const pidsToKill = [...(targetStep.pids || [])];
347
+ if (targetStep.pid && !pidsToKill.includes(targetStep.pid)) pidsToKill.push(targetStep.pid);
348
+ for (const pid of pidsToKill) {
349
+ killGroup(pid);
350
+ }
351
+
335
352
  targetStep.pid = null;
353
+ targetStep.pids = [];
336
354
  targetStep.exitCode = null;
337
355
  targetStep.signaled = false;
338
356
 
package/src/server.js CHANGED
@@ -112,7 +112,7 @@ export function createServer() {
112
112
  }
113
113
 
114
114
  const server = new Server(
115
- { name: 'agent-pool', version: '1.5.0' },
115
+ { name: 'agent-pool', version: '1.6.0' },
116
116
  { capabilities: { tools: {}, resources: {} } },
117
117
  );
118
118