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 +1 -1
- package/src/scheduler/daemon.js +158 -57
- package/src/scheduler/pipeline.js +21 -3
- package/src/server.js +1 -1
package/package.json
CHANGED
package/src/scheduler/daemon.js
CHANGED
|
@@ -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
|
|
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
|
|
177
|
+
* @returns {number[]} Array of child PIDs
|
|
176
178
|
*/
|
|
177
179
|
function spawnStep(stepDef, run, runId, bounceReason) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
} catch { /* ignore */ }
|
|
208
|
-
console.error(`[pipeline] Step "${stepDef.name}" exited (code: ${code}, run: ${runId})`);
|
|
209
|
-
});
|
|
225
|
+
}
|
|
210
226
|
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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'
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
223
|
-
|
|
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