@gjczone/pi-swarm 0.1.2
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/LICENSE +21 -0
- package/README.md +124 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +99 -0
- package/dist/index.js.map +1 -0
- package/dist/shared/controller.d.ts +86 -0
- package/dist/shared/controller.d.ts.map +1 -0
- package/dist/shared/controller.js +662 -0
- package/dist/shared/controller.js.map +1 -0
- package/dist/shared/pi-invoke.d.ts +31 -0
- package/dist/shared/pi-invoke.d.ts.map +1 -0
- package/dist/shared/pi-invoke.js +54 -0
- package/dist/shared/pi-invoke.js.map +1 -0
- package/dist/shared/render.d.ts +44 -0
- package/dist/shared/render.d.ts.map +1 -0
- package/dist/shared/render.js +116 -0
- package/dist/shared/render.js.map +1 -0
- package/dist/shared/spawner.d.ts +26 -0
- package/dist/shared/spawner.d.ts.map +1 -0
- package/dist/shared/spawner.js +226 -0
- package/dist/shared/spawner.js.map +1 -0
- package/dist/shared/types.d.ts +182 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +8 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/state/persistence.d.ts +83 -0
- package/dist/state/persistence.d.ts.map +1 -0
- package/dist/state/persistence.js +215 -0
- package/dist/state/persistence.js.map +1 -0
- package/dist/state/recovery.d.ts +35 -0
- package/dist/state/recovery.d.ts.map +1 -0
- package/dist/state/recovery.js +149 -0
- package/dist/state/recovery.js.map +1 -0
- package/dist/swarm/command.d.ts +36 -0
- package/dist/swarm/command.d.ts.map +1 -0
- package/dist/swarm/command.js +113 -0
- package/dist/swarm/command.js.map +1 -0
- package/dist/swarm/mode.d.ts +58 -0
- package/dist/swarm/mode.d.ts.map +1 -0
- package/dist/swarm/mode.js +87 -0
- package/dist/swarm/mode.js.map +1 -0
- package/dist/swarm/tool.d.ts +11 -0
- package/dist/swarm/tool.d.ts.map +1 -0
- package/dist/swarm/tool.js +190 -0
- package/dist/swarm/tool.js.map +1 -0
- package/dist/team/command.d.ts +11 -0
- package/dist/team/command.d.ts.map +1 -0
- package/dist/team/command.js +32 -0
- package/dist/team/command.js.map +1 -0
- package/dist/team/mailbox.d.ts +61 -0
- package/dist/team/mailbox.d.ts.map +1 -0
- package/dist/team/mailbox.js +160 -0
- package/dist/team/mailbox.js.map +1 -0
- package/dist/team/supervisor.d.ts +77 -0
- package/dist/team/supervisor.d.ts.map +1 -0
- package/dist/team/supervisor.js +195 -0
- package/dist/team/supervisor.js.map +1 -0
- package/dist/team/task-graph.d.ts +61 -0
- package/dist/team/task-graph.d.ts.map +1 -0
- package/dist/team/task-graph.js +193 -0
- package/dist/team/task-graph.js.map +1 -0
- package/dist/team/tool.d.ts +11 -0
- package/dist/team/tool.d.ts.map +1 -0
- package/dist/team/tool.js +210 -0
- package/dist/team/tool.js.map +1 -0
- package/dist/tui/permission-prompt.d.ts +26 -0
- package/dist/tui/permission-prompt.d.ts.map +1 -0
- package/dist/tui/permission-prompt.js +94 -0
- package/dist/tui/permission-prompt.js.map +1 -0
- package/dist/tui/progress.d.ts +64 -0
- package/dist/tui/progress.d.ts.map +1 -0
- package/dist/tui/progress.js +260 -0
- package/dist/tui/progress.js.map +1 -0
- package/dist/tui/swarm-markers.d.ts +20 -0
- package/dist/tui/swarm-markers.d.ts.map +1 -0
- package/dist/tui/swarm-markers.js +61 -0
- package/dist/tui/swarm-markers.js.map +1 -0
- package/package.json +58 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* controller — concurrency controller for subagent batches.
|
|
3
|
+
*
|
|
4
|
+
* Ported from MoonshotAI/kimi-code's SubagentBatch.
|
|
5
|
+
*
|
|
6
|
+
* Two-phase scheduling:
|
|
7
|
+
* Normal phase: ramp-up (5 initial, +1 every 700ms).
|
|
8
|
+
* Rate-limit phase: capacity tracking with exponential backoff retries.
|
|
9
|
+
*
|
|
10
|
+
* Environment variables:
|
|
11
|
+
* PI_SWARM_MAX_CONCURRENCY — cap on concurrent subagents (optional).
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Constants (from kimi-code)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const INITIAL_LAUNCH_LIMIT = 5;
|
|
19
|
+
const INITIAL_LAUNCH_INTERVAL_MS = 700;
|
|
20
|
+
const RATE_LIMIT_RETRY_BASE_MS = 3000;
|
|
21
|
+
const RATE_LIMIT_RETRY_FACTOR = 2;
|
|
22
|
+
const RATE_LIMIT_CAPACITY_SHRINK_INTERVAL_MS = 2000;
|
|
23
|
+
const RATE_LIMIT_CAPACITY_RECOVERY_INTERVAL_MS = 3 * 60 * 1000; // 3 minutes
|
|
24
|
+
const AGENT_SWARM_MAX_CONCURRENCY_ENV = "PI_SWARM_MAX_CONCURRENCY";
|
|
25
|
+
const DEFAULT_MAX_CONCURRENCY = 5;
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Abort helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/**
|
|
30
|
+
* Check whether an abort reason indicates user cancellation
|
|
31
|
+
* (as opposed to a programmatic abort).
|
|
32
|
+
*/
|
|
33
|
+
function isUserCancellation(reason) {
|
|
34
|
+
if (reason === undefined)
|
|
35
|
+
return false;
|
|
36
|
+
const msg = reason instanceof Error
|
|
37
|
+
? reason.message.toLowerCase()
|
|
38
|
+
: String(reason).toLowerCase();
|
|
39
|
+
return (msg.includes("user") ||
|
|
40
|
+
msg.includes("cancel") ||
|
|
41
|
+
msg.includes("interrupt") ||
|
|
42
|
+
msg.includes("abort"));
|
|
43
|
+
}
|
|
44
|
+
function userCancellationReason() {
|
|
45
|
+
return new Error("User cancelled");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Link two AbortSignals so that either one aborting aborts the other.
|
|
49
|
+
* Returns a cleanup function.
|
|
50
|
+
*/
|
|
51
|
+
function linkAbortSignal(source, target) {
|
|
52
|
+
const handler = () => {
|
|
53
|
+
target.abort(source.reason);
|
|
54
|
+
};
|
|
55
|
+
if (source.aborted) {
|
|
56
|
+
target.abort(source.reason);
|
|
57
|
+
return () => { };
|
|
58
|
+
}
|
|
59
|
+
source.addEventListener("abort", handler, { once: true });
|
|
60
|
+
return () => source.removeEventListener("abort", handler);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Detect whether an error indicates a provider rate limit.
|
|
64
|
+
*/
|
|
65
|
+
function isProviderRateLimitError(error) {
|
|
66
|
+
if (!(error instanceof Error))
|
|
67
|
+
return false;
|
|
68
|
+
const msg = error.message.toLowerCase();
|
|
69
|
+
return (msg.includes("rate limit") ||
|
|
70
|
+
msg.includes("rate_limit") ||
|
|
71
|
+
msg.includes("ratelimit") ||
|
|
72
|
+
msg.includes("429") ||
|
|
73
|
+
msg.includes("too many requests") ||
|
|
74
|
+
msg.includes("quota"));
|
|
75
|
+
}
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Controller
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
export class SubagentBatchController {
|
|
80
|
+
launcher;
|
|
81
|
+
states;
|
|
82
|
+
pending;
|
|
83
|
+
results;
|
|
84
|
+
active = new Set();
|
|
85
|
+
controller = new AbortController();
|
|
86
|
+
batchSignal;
|
|
87
|
+
batchAbortListener;
|
|
88
|
+
maxConcurrency;
|
|
89
|
+
// Normal phase state
|
|
90
|
+
normalLaunchCount = 0;
|
|
91
|
+
normalLaunchTimer;
|
|
92
|
+
// Rate-limit phase state
|
|
93
|
+
rateLimitLaunchTimer;
|
|
94
|
+
rateLimitMode = false;
|
|
95
|
+
rateLimitCapacity = 1;
|
|
96
|
+
lastRateLimitAt;
|
|
97
|
+
lastCapacityShrinkAt;
|
|
98
|
+
lastCapacityRecoveryAt;
|
|
99
|
+
globalRetryIntervalMs = RATE_LIMIT_RETRY_BASE_MS;
|
|
100
|
+
nextRateLimitLaunchAt = 0;
|
|
101
|
+
// Promise control
|
|
102
|
+
resolve;
|
|
103
|
+
reject;
|
|
104
|
+
finished = false;
|
|
105
|
+
started = false;
|
|
106
|
+
startedSuccessCount = 0;
|
|
107
|
+
constructor(launcher, tasks, options = {}) {
|
|
108
|
+
this.launcher = launcher;
|
|
109
|
+
this.maxConcurrency = options.maxConcurrency;
|
|
110
|
+
this.states = tasks.map((task, index) => ({
|
|
111
|
+
index,
|
|
112
|
+
task,
|
|
113
|
+
retryCount: 0,
|
|
114
|
+
retryReadyAt: 0,
|
|
115
|
+
started: false,
|
|
116
|
+
}));
|
|
117
|
+
this.pending = [...this.states];
|
|
118
|
+
this.results = Array.from({
|
|
119
|
+
length: tasks.length,
|
|
120
|
+
}).fill(undefined);
|
|
121
|
+
// Use the first task's signal as the batch signal
|
|
122
|
+
this.batchSignal = tasks.find((t) => t.signal !== undefined)?.signal;
|
|
123
|
+
this.batchAbortListener = () => {
|
|
124
|
+
this.controller.abort(this.batchSignal?.reason);
|
|
125
|
+
if (isUserCancellation(this.batchSignal?.reason)) {
|
|
126
|
+
this.finishWithUserCancellation();
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
this.fail(this.batchSignal?.reason ?? new Error("Aborted"));
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// -----------------------------------------------------------------------
|
|
134
|
+
// Public API
|
|
135
|
+
// -----------------------------------------------------------------------
|
|
136
|
+
/**
|
|
137
|
+
* Run the batch. Returns a promise that resolves when all tasks
|
|
138
|
+
* have a terminal result, or rejects on non-user cancellation.
|
|
139
|
+
*/
|
|
140
|
+
run() {
|
|
141
|
+
if (this.started) {
|
|
142
|
+
throw new Error("SubagentBatchController.run() can only be called once.");
|
|
143
|
+
}
|
|
144
|
+
this.started = true;
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
this.resolve = resolve;
|
|
147
|
+
this.reject = reject;
|
|
148
|
+
if (this.states.length === 0) {
|
|
149
|
+
this.finish([]);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (this.batchSignal?.aborted) {
|
|
153
|
+
this.batchAbortListener();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.batchSignal?.addEventListener("abort", this.batchAbortListener, { once: true });
|
|
157
|
+
this.schedule();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// -----------------------------------------------------------------------
|
|
161
|
+
// Scheduling
|
|
162
|
+
// -----------------------------------------------------------------------
|
|
163
|
+
schedule() {
|
|
164
|
+
if (this.finished)
|
|
165
|
+
return;
|
|
166
|
+
if (this.finishIfComplete())
|
|
167
|
+
return;
|
|
168
|
+
if (this.controller.signal.aborted)
|
|
169
|
+
return;
|
|
170
|
+
if (this.rateLimitMode) {
|
|
171
|
+
this.scheduleRateLimitLaunch();
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
this.scheduleNormalLaunch();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
scheduleNormalLaunch() {
|
|
178
|
+
// Launch up to INITIAL_LAUNCH_LIMIT immediately
|
|
179
|
+
while (this.normalLaunchCount < INITIAL_LAUNCH_LIMIT &&
|
|
180
|
+
this.pending.length > 0 &&
|
|
181
|
+
!this.rateLimitMode &&
|
|
182
|
+
!this.isAtConcurrencyLimit()) {
|
|
183
|
+
const state = this.pending.shift();
|
|
184
|
+
if (state) {
|
|
185
|
+
this.startAttempt(state);
|
|
186
|
+
this.normalLaunchCount += 1;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Schedule next ramp launch if work remains
|
|
190
|
+
if (this.pending.length === 0 ||
|
|
191
|
+
this.rateLimitMode ||
|
|
192
|
+
this.normalLaunchTimer !== undefined ||
|
|
193
|
+
this.isAtConcurrencyLimit()) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
this.normalLaunchTimer = setTimeout(() => {
|
|
197
|
+
this.normalLaunchTimer = undefined;
|
|
198
|
+
if (this.finished ||
|
|
199
|
+
this.rateLimitMode ||
|
|
200
|
+
this.pending.length === 0)
|
|
201
|
+
return;
|
|
202
|
+
if (this.isAtConcurrencyLimit())
|
|
203
|
+
return;
|
|
204
|
+
const state = this.pending.shift();
|
|
205
|
+
if (state) {
|
|
206
|
+
this.startAttempt(state);
|
|
207
|
+
this.normalLaunchCount += 1;
|
|
208
|
+
}
|
|
209
|
+
this.schedule();
|
|
210
|
+
}, INITIAL_LAUNCH_INTERVAL_MS);
|
|
211
|
+
}
|
|
212
|
+
isAtConcurrencyLimit() {
|
|
213
|
+
return (this.maxConcurrency !== undefined &&
|
|
214
|
+
this.active.size >= this.maxConcurrency);
|
|
215
|
+
}
|
|
216
|
+
scheduleRateLimitLaunch() {
|
|
217
|
+
this.clearRateLimitTimer();
|
|
218
|
+
if (this.pending.length === 0)
|
|
219
|
+
return;
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
this.recoverRateLimitCapacity(now);
|
|
222
|
+
if (this.active.size >= this.rateLimitCapacity) {
|
|
223
|
+
this.scheduleRateLimitWakeup(this.nextRateLimitCapacityRecoveryAt(), now);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const nextAllowedAt = Math.max(this.nextRateLimitLaunchAt, this.nextPendingReadyAt());
|
|
227
|
+
const nextWakeupAt = Math.min(nextAllowedAt, this.nextRateLimitCapacityRecoveryAt());
|
|
228
|
+
if (nextWakeupAt > now) {
|
|
229
|
+
this.scheduleRateLimitWakeup(nextWakeupAt, now);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const pendingIndex = this.pending.findIndex((state) => state.retryReadyAt <= now);
|
|
233
|
+
if (pendingIndex === -1)
|
|
234
|
+
return;
|
|
235
|
+
const [state] = this.pending.splice(pendingIndex, 1);
|
|
236
|
+
if (state) {
|
|
237
|
+
this.startAttempt(state);
|
|
238
|
+
this.nextRateLimitLaunchAt = now + this.globalRetryIntervalMs;
|
|
239
|
+
this.scheduleNextRateLimitWakeup(now);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// -----------------------------------------------------------------------
|
|
243
|
+
// Attempt lifecycle
|
|
244
|
+
// -----------------------------------------------------------------------
|
|
245
|
+
startAttempt(state) {
|
|
246
|
+
if (this.finished || this.controller.signal.aborted)
|
|
247
|
+
return;
|
|
248
|
+
const attempt = {
|
|
249
|
+
state,
|
|
250
|
+
controller: new AbortController(),
|
|
251
|
+
cleanup: () => { },
|
|
252
|
+
ready: false,
|
|
253
|
+
timedOut: false,
|
|
254
|
+
};
|
|
255
|
+
attempt.cleanup = this.linkAttemptSignals(attempt, state.task);
|
|
256
|
+
this.active.add(attempt);
|
|
257
|
+
this.runAttempt(attempt).then((outcome) => {
|
|
258
|
+
this.handleAttemptOutcome(attempt, outcome);
|
|
259
|
+
}, (error) => {
|
|
260
|
+
this.handleAttemptError(attempt, error);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
async runAttempt(attempt) {
|
|
264
|
+
const task = attempt.state.task;
|
|
265
|
+
const runOptions = {
|
|
266
|
+
parentToolCallId: task.parentToolCallId,
|
|
267
|
+
parentToolCallUuid: task.parentToolCallUuid,
|
|
268
|
+
prompt: task.prompt,
|
|
269
|
+
description: task.description,
|
|
270
|
+
swarmIndex: task.swarmIndex,
|
|
271
|
+
runInBackground: task.runInBackground,
|
|
272
|
+
signal: attempt.controller.signal,
|
|
273
|
+
onReady: () => {
|
|
274
|
+
this.markAttemptReady(attempt);
|
|
275
|
+
},
|
|
276
|
+
suppressRateLimitFailureEvent: true,
|
|
277
|
+
timeout: task.timeout,
|
|
278
|
+
};
|
|
279
|
+
let handle;
|
|
280
|
+
try {
|
|
281
|
+
attempt.controller.signal.throwIfAborted();
|
|
282
|
+
if (attempt.state.retryAgentId !== undefined) {
|
|
283
|
+
handle = await this.launcher.retry(attempt.state.retryAgentId, runOptions);
|
|
284
|
+
}
|
|
285
|
+
else if (task.kind === "resume") {
|
|
286
|
+
handle = await this.launcher.resume(task.resumeAgentId, runOptions);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
const spawnOptions = {
|
|
290
|
+
profileName: task.profileName,
|
|
291
|
+
swarmItem: task.swarmItem,
|
|
292
|
+
...runOptions,
|
|
293
|
+
};
|
|
294
|
+
handle = await this.launcher.spawn(spawnOptions);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
return this.failedAttemptOutcome(attempt, error);
|
|
299
|
+
}
|
|
300
|
+
attempt.state.agentId = handle.agentId;
|
|
301
|
+
try {
|
|
302
|
+
const completion = await handle.completion;
|
|
303
|
+
return {
|
|
304
|
+
task,
|
|
305
|
+
agentId: handle.agentId,
|
|
306
|
+
status: "completed",
|
|
307
|
+
result: completion.result,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
if (isProviderRateLimitError(error)) {
|
|
312
|
+
return {
|
|
313
|
+
type: "rate_limited",
|
|
314
|
+
task,
|
|
315
|
+
agentId: handle.agentId,
|
|
316
|
+
status: "failed",
|
|
317
|
+
error: String(error),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
return this.failedAttemptOutcome(attempt, error);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// -----------------------------------------------------------------------
|
|
324
|
+
// Outcome handling
|
|
325
|
+
// -----------------------------------------------------------------------
|
|
326
|
+
handleAttemptOutcome(attempt, outcome) {
|
|
327
|
+
attempt.cleanup();
|
|
328
|
+
this.active.delete(attempt);
|
|
329
|
+
if (outcome.type === "rate_limited") {
|
|
330
|
+
this.handleRateLimit(attempt, outcome);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
this.results[attempt.state.index] = outcome;
|
|
334
|
+
this.schedule();
|
|
335
|
+
}
|
|
336
|
+
handleAttemptError(attempt, error) {
|
|
337
|
+
attempt.cleanup();
|
|
338
|
+
this.active.delete(attempt);
|
|
339
|
+
if (isProviderRateLimitError(error)) {
|
|
340
|
+
this.handleRateLimit(attempt, {
|
|
341
|
+
type: "rate_limited",
|
|
342
|
+
task: attempt.state.task,
|
|
343
|
+
agentId: attempt.state.agentId,
|
|
344
|
+
status: "failed",
|
|
345
|
+
error: String(error),
|
|
346
|
+
});
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const result = this.failedAttemptOutcome(attempt, error);
|
|
350
|
+
this.results[attempt.state.index] = result;
|
|
351
|
+
this.schedule();
|
|
352
|
+
}
|
|
353
|
+
handleRateLimit(attempt, _outcome) {
|
|
354
|
+
const state = attempt.state;
|
|
355
|
+
// If this is the only remaining task, fail fast.
|
|
356
|
+
if (this.pending.length === 0 && this.active.size === 0) {
|
|
357
|
+
this.results[state.index] = this.failedAttemptOutcome(attempt, new Error("Rate limit exceeded with no remaining work."));
|
|
358
|
+
this.schedule();
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// Save agent id for retry and requeue at front
|
|
362
|
+
state.retryAgentId = state.agentId ?? state.retryAgentId;
|
|
363
|
+
state.retryCount += 1;
|
|
364
|
+
// Exponential backoff
|
|
365
|
+
const delay = RATE_LIMIT_RETRY_BASE_MS *
|
|
366
|
+
Math.pow(RATE_LIMIT_RETRY_FACTOR, state.retryCount - 1);
|
|
367
|
+
state.retryReadyAt = Date.now() + delay;
|
|
368
|
+
this.pending.unshift(state);
|
|
369
|
+
// Enter rate-limit phase
|
|
370
|
+
if (!this.rateLimitMode) {
|
|
371
|
+
this.enterRateLimitPhase();
|
|
372
|
+
}
|
|
373
|
+
this.shrinkRateLimitCapacity(Date.now());
|
|
374
|
+
this.schedule();
|
|
375
|
+
}
|
|
376
|
+
failedAttemptOutcome(attempt, error) {
|
|
377
|
+
const task = attempt.state.task;
|
|
378
|
+
const isAbort = error instanceof Error &&
|
|
379
|
+
(error.message.includes("abort") ||
|
|
380
|
+
error.message.includes("cancel") ||
|
|
381
|
+
error.name === "AbortError");
|
|
382
|
+
const status = isAbort
|
|
383
|
+
? "aborted"
|
|
384
|
+
: "failed";
|
|
385
|
+
let errorMessage;
|
|
386
|
+
if (attempt.timedOut && task.timeout !== undefined) {
|
|
387
|
+
errorMessage = "Subagent timed out.";
|
|
388
|
+
}
|
|
389
|
+
else if (isAbort) {
|
|
390
|
+
errorMessage =
|
|
391
|
+
"The user manually interrupted this subagent batch.";
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
errorMessage =
|
|
395
|
+
error instanceof Error
|
|
396
|
+
? error.message
|
|
397
|
+
: String(error);
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
task,
|
|
401
|
+
agentId: attempt.state.agentId,
|
|
402
|
+
status,
|
|
403
|
+
state: attempt.state.started ? "started" : "not_started",
|
|
404
|
+
error: errorMessage,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
// -----------------------------------------------------------------------
|
|
408
|
+
// Rate-limit phase
|
|
409
|
+
// -----------------------------------------------------------------------
|
|
410
|
+
enterRateLimitPhase() {
|
|
411
|
+
this.rateLimitMode = true;
|
|
412
|
+
this.clearNormalTimer();
|
|
413
|
+
this.rateLimitCapacity = Math.max(1, this.countReadyActive());
|
|
414
|
+
this.lastRateLimitAt = Date.now();
|
|
415
|
+
this.globalRetryIntervalMs = RATE_LIMIT_RETRY_BASE_MS;
|
|
416
|
+
this.nextRateLimitLaunchAt = Date.now() + RATE_LIMIT_RETRY_BASE_MS;
|
|
417
|
+
}
|
|
418
|
+
shrinkRateLimitCapacity(now) {
|
|
419
|
+
if (this.lastCapacityShrinkAt !== undefined &&
|
|
420
|
+
now - this.lastCapacityShrinkAt <
|
|
421
|
+
RATE_LIMIT_CAPACITY_SHRINK_INTERVAL_MS) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
this.lastCapacityShrinkAt = now;
|
|
425
|
+
this.rateLimitCapacity = Math.max(1, this.rateLimitCapacity - 1);
|
|
426
|
+
this.globalRetryIntervalMs = Math.min(this.globalRetryIntervalMs * RATE_LIMIT_RETRY_FACTOR, 120_000);
|
|
427
|
+
}
|
|
428
|
+
recoverRateLimitCapacity(now) {
|
|
429
|
+
if (this.lastRateLimitAt === undefined)
|
|
430
|
+
return;
|
|
431
|
+
const quietPeriod = now - this.lastRateLimitAt;
|
|
432
|
+
if (quietPeriod < RATE_LIMIT_CAPACITY_RECOVERY_INTERVAL_MS)
|
|
433
|
+
return;
|
|
434
|
+
const lastRecovery = this.lastCapacityRecoveryAt ?? 0;
|
|
435
|
+
if (now - lastRecovery < RATE_LIMIT_CAPACITY_RECOVERY_INTERVAL_MS)
|
|
436
|
+
return;
|
|
437
|
+
this.lastCapacityRecoveryAt = now;
|
|
438
|
+
this.rateLimitCapacity += 1;
|
|
439
|
+
}
|
|
440
|
+
// -----------------------------------------------------------------------
|
|
441
|
+
// Completion
|
|
442
|
+
// -----------------------------------------------------------------------
|
|
443
|
+
finishIfComplete() {
|
|
444
|
+
const allDone = this.results.every((r) => r !== undefined);
|
|
445
|
+
if (allDone) {
|
|
446
|
+
this.finish(this.results);
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
finish(results) {
|
|
452
|
+
if (this.finished)
|
|
453
|
+
return;
|
|
454
|
+
this.finished = true;
|
|
455
|
+
this.clearNormalTimer();
|
|
456
|
+
this.clearRateLimitTimer();
|
|
457
|
+
this.batchSignal?.removeEventListener("abort", this.batchAbortListener);
|
|
458
|
+
this.resolve?.(results);
|
|
459
|
+
}
|
|
460
|
+
finishWithUserCancellation() {
|
|
461
|
+
if (this.finished)
|
|
462
|
+
return;
|
|
463
|
+
// Preserve existing results
|
|
464
|
+
for (let i = 0; i < this.states.length; i += 1) {
|
|
465
|
+
if (this.results[i] !== undefined)
|
|
466
|
+
continue;
|
|
467
|
+
const state = this.states[i];
|
|
468
|
+
if (state.started || state.agentId !== undefined) {
|
|
469
|
+
this.results[i] = {
|
|
470
|
+
task: state.task,
|
|
471
|
+
agentId: state.agentId,
|
|
472
|
+
status: "aborted",
|
|
473
|
+
state: "started",
|
|
474
|
+
error: "Cancelled by user.",
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
this.results[i] = {
|
|
479
|
+
task: state.task,
|
|
480
|
+
status: "aborted",
|
|
481
|
+
state: "not_started",
|
|
482
|
+
error: "Cancelled by user.",
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
this.finish(this.results);
|
|
487
|
+
}
|
|
488
|
+
fail(error) {
|
|
489
|
+
if (this.finished)
|
|
490
|
+
return;
|
|
491
|
+
this.finished = true;
|
|
492
|
+
this.clearNormalTimer();
|
|
493
|
+
this.clearRateLimitTimer();
|
|
494
|
+
// Abort all active attempts
|
|
495
|
+
for (const attempt of this.active) {
|
|
496
|
+
attempt.cleanup();
|
|
497
|
+
}
|
|
498
|
+
this.active.clear();
|
|
499
|
+
this.batchSignal?.removeEventListener("abort", this.batchAbortListener);
|
|
500
|
+
this.reject?.(error);
|
|
501
|
+
}
|
|
502
|
+
// -----------------------------------------------------------------------
|
|
503
|
+
// Timer management
|
|
504
|
+
// -----------------------------------------------------------------------
|
|
505
|
+
clearNormalTimer() {
|
|
506
|
+
if (this.normalLaunchTimer !== undefined) {
|
|
507
|
+
clearTimeout(this.normalLaunchTimer);
|
|
508
|
+
this.normalLaunchTimer = undefined;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
clearRateLimitTimer() {
|
|
512
|
+
if (this.rateLimitLaunchTimer !== undefined) {
|
|
513
|
+
clearTimeout(this.rateLimitLaunchTimer);
|
|
514
|
+
this.rateLimitLaunchTimer = undefined;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
scheduleRateLimitWakeup(wakeAt, now) {
|
|
518
|
+
const delay = Math.max(0, wakeAt - now);
|
|
519
|
+
this.rateLimitLaunchTimer = setTimeout(() => {
|
|
520
|
+
this.rateLimitLaunchTimer = undefined;
|
|
521
|
+
this.schedule();
|
|
522
|
+
}, delay);
|
|
523
|
+
}
|
|
524
|
+
scheduleNextRateLimitWakeup(now) {
|
|
525
|
+
const next = Math.min(this.nextRateLimitLaunchAt, this.nextPendingReadyAt(), this.nextRateLimitCapacityRecoveryAt());
|
|
526
|
+
if (next > now) {
|
|
527
|
+
this.scheduleRateLimitWakeup(next, now);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// -----------------------------------------------------------------------
|
|
531
|
+
// Helpers
|
|
532
|
+
// -----------------------------------------------------------------------
|
|
533
|
+
markAttemptReady(attempt) {
|
|
534
|
+
attempt.ready = true;
|
|
535
|
+
if (!this.rateLimitMode &&
|
|
536
|
+
this.startedSuccessCount === 0) {
|
|
537
|
+
this.startedSuccessCount = 1;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
countReadyActive() {
|
|
541
|
+
let count = 0;
|
|
542
|
+
for (const a of this.active) {
|
|
543
|
+
if (a.ready)
|
|
544
|
+
count += 1;
|
|
545
|
+
}
|
|
546
|
+
return count;
|
|
547
|
+
}
|
|
548
|
+
nextPendingReadyAt() {
|
|
549
|
+
let earliest = Infinity;
|
|
550
|
+
for (const state of this.pending) {
|
|
551
|
+
if (state.retryReadyAt < earliest) {
|
|
552
|
+
earliest = state.retryReadyAt;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return earliest === Infinity ? 0 : earliest;
|
|
556
|
+
}
|
|
557
|
+
nextRateLimitCapacityRecoveryAt() {
|
|
558
|
+
if (this.lastRateLimitAt === undefined)
|
|
559
|
+
return Infinity;
|
|
560
|
+
const nextRecovery = (this.lastCapacityRecoveryAt ?? this.lastRateLimitAt) +
|
|
561
|
+
RATE_LIMIT_CAPACITY_RECOVERY_INTERVAL_MS;
|
|
562
|
+
return nextRecovery;
|
|
563
|
+
}
|
|
564
|
+
linkAttemptSignals(attempt, task) {
|
|
565
|
+
const abortFromBatch = () => {
|
|
566
|
+
attempt.controller.abort(this.controller.signal.reason);
|
|
567
|
+
};
|
|
568
|
+
const abortFromTask = () => {
|
|
569
|
+
attempt.controller.abort(task.signal?.reason);
|
|
570
|
+
};
|
|
571
|
+
let timeoutHandle;
|
|
572
|
+
if (task.timeout !== undefined && task.timeout > 0) {
|
|
573
|
+
timeoutHandle = setTimeout(() => {
|
|
574
|
+
attempt.timedOut = true;
|
|
575
|
+
attempt.controller.abort(new Error("Aborted"));
|
|
576
|
+
}, task.timeout);
|
|
577
|
+
}
|
|
578
|
+
if (this.controller.signal.aborted) {
|
|
579
|
+
abortFromBatch();
|
|
580
|
+
}
|
|
581
|
+
else if (task.signal?.aborted) {
|
|
582
|
+
abortFromTask();
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
this.controller.signal.addEventListener("abort", abortFromBatch, { once: true });
|
|
586
|
+
task.signal?.addEventListener("abort", abortFromTask, {
|
|
587
|
+
once: true,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
return () => {
|
|
591
|
+
if (timeoutHandle !== undefined)
|
|
592
|
+
clearTimeout(timeoutHandle);
|
|
593
|
+
this.controller.signal.removeEventListener("abort", abortFromBatch);
|
|
594
|
+
task.signal?.removeEventListener("abort", abortFromTask);
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
// Environment helpers
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
/**
|
|
602
|
+
* Resolve the optional swarm max concurrency from pi settings.json
|
|
603
|
+
* or the environment variable.
|
|
604
|
+
*
|
|
605
|
+
* Priority:
|
|
606
|
+
* 1. `.pi/settings.json` → `pi-swarm.maxConcurrency` (project-local)
|
|
607
|
+
* 2. `~/.pi/agent/settings.json` → `pi-swarm.maxConcurrency` (global)
|
|
608
|
+
* 3. `PI_SWARM_MAX_CONCURRENCY` env var
|
|
609
|
+
*
|
|
610
|
+
* Returns `undefined` when unset. A present value must be a positive
|
|
611
|
+
* integer; invalid input throws so a misconfigured cap never silently
|
|
612
|
+
* reverts to uncapped.
|
|
613
|
+
*/
|
|
614
|
+
export function resolveSwarmMaxConcurrency(cwd) {
|
|
615
|
+
// 1. Project-local settings
|
|
616
|
+
const projectSettings = readPiSettings(path.join(cwd ?? process.cwd(), ".pi", "settings.json"));
|
|
617
|
+
const projectValue = getSettingsMaxConcurrency(projectSettings);
|
|
618
|
+
if (projectValue !== undefined) {
|
|
619
|
+
return validateConcurrency(projectValue, ".pi/settings.json");
|
|
620
|
+
}
|
|
621
|
+
// 2. Global settings
|
|
622
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "~";
|
|
623
|
+
const globalSettings = readPiSettings(path.join(home, ".pi", "agent", "settings.json"));
|
|
624
|
+
const globalValue = getSettingsMaxConcurrency(globalSettings);
|
|
625
|
+
if (globalValue !== undefined) {
|
|
626
|
+
return validateConcurrency(globalValue, "~/.pi/agent/settings.json");
|
|
627
|
+
}
|
|
628
|
+
// 3. Environment variable
|
|
629
|
+
const raw = process.env[AGENT_SWARM_MAX_CONCURRENCY_ENV];
|
|
630
|
+
if (raw !== undefined && raw.trim() !== "") {
|
|
631
|
+
return validateConcurrency(Number(raw), AGENT_SWARM_MAX_CONCURRENCY_ENV);
|
|
632
|
+
}
|
|
633
|
+
// 4. Default
|
|
634
|
+
return DEFAULT_MAX_CONCURRENCY;
|
|
635
|
+
}
|
|
636
|
+
function validateConcurrency(value, source) {
|
|
637
|
+
if (value === undefined || value === null)
|
|
638
|
+
return undefined;
|
|
639
|
+
const num = Number(value);
|
|
640
|
+
if (!Number.isInteger(num) || num <= 0) {
|
|
641
|
+
throw new Error(`pi-swarm.maxConcurrency in ${source} must be a positive integer, got ${JSON.stringify(value)}.`);
|
|
642
|
+
}
|
|
643
|
+
return num;
|
|
644
|
+
}
|
|
645
|
+
function readPiSettings(filePath) {
|
|
646
|
+
try {
|
|
647
|
+
if (!fs.existsSync(filePath))
|
|
648
|
+
return null;
|
|
649
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
650
|
+
return JSON.parse(raw);
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
function getSettingsMaxConcurrency(settings) {
|
|
657
|
+
if (!settings)
|
|
658
|
+
return undefined;
|
|
659
|
+
const swarm = settings["pi-swarm"];
|
|
660
|
+
return swarm?.maxConcurrency;
|
|
661
|
+
}
|
|
662
|
+
//# sourceMappingURL=controller.js.map
|