@d3ara1n/pi-subagent 0.10.0 → 0.10.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/src/utils.test.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * node --test packages/pi-subagent/src/utils.test.ts
6
6
  *
7
7
  * These guard the bug fixes introduced during the improvement rounds:
8
- * path-injection (sanitizeFilename), concurrency/abort/negative-active
9
- * (AsyncSemaphore), provider-error word list (isProviderError), unknown-tool
8
+ * path-injection (sanitizeFilename), concurrency/abort/negative-active/unlimited
9
+ * semantics (AsyncSemaphore), provider-error word list (isProviderError), unknown-tool
10
10
  * formatting (previewArgs), output truncation fallback (truncateOutput).
11
11
  */
12
12
 
@@ -146,6 +146,40 @@ describe("AsyncSemaphore", () => {
146
146
  await assert.rejects(p);
147
147
  assert.equal((s as any).waiters.length, 0);
148
148
  });
149
+ test("unlimited max (0) never queues or reports capacity", async () => {
150
+ const s = new AsyncSemaphore(0);
151
+ assert.equal(s.isLimited, false);
152
+ assert.equal(s.isAtCapacity, false);
153
+
154
+ let acquired = 0;
155
+ await Promise.all(
156
+ Array.from({ length: 10 }, () =>
157
+ s.acquire().then(() => {
158
+ acquired++;
159
+ }),
160
+ ),
161
+ );
162
+
163
+ assert.equal(acquired, 10);
164
+ assert.equal((s as any).waiters.length, 0);
165
+ assert.equal(s.isAtCapacity, false);
166
+ });
167
+ test("positive max reports capacity and retains FIFO queueing", async () => {
168
+ const s = new AsyncSemaphore(1);
169
+ await s.acquire();
170
+ assert.equal(s.isAtCapacity, true);
171
+
172
+ const order: number[] = [];
173
+ const p1 = s.acquire().then(() => order.push(1));
174
+ const p2 = s.acquire().then(() => order.push(2));
175
+ assert.equal((s as any).waiters.length, 2);
176
+
177
+ s.release();
178
+ await p1;
179
+ s.release();
180
+ await p2;
181
+ assert.deepEqual(order, [1, 2]);
182
+ });
149
183
  test("releases queued waiters in FIFO order", async () => {
150
184
  const s = new AsyncSemaphore(1);
151
185
  await s.acquire();
@@ -214,17 +248,18 @@ describe("effectiveTimeout", () => {
214
248
  timeout,
215
249
  }) as unknown as SubagentRole;
216
250
 
217
- test("non-delegate role uses base timeout", () => {
218
- assert.equal(effectiveTimeout(role(["read", "grep"]), 600), 600);
251
+ test("role without timeout is unlimited", () => {
252
+ assert.equal(effectiveTimeout(role(["read", "grep"])), 0);
219
253
  });
220
- test("delegate role uses base timeout (no widening — active-time clock pauses for nested delegate)", () => {
221
- assert.equal(effectiveTimeout(role(["read", "delegate"]), 600), 600);
254
+ test("delegate-capable role without timeout is also unlimited", () => {
255
+ assert.equal(effectiveTimeout(role(["read", "delegate"])), 0);
222
256
  });
223
- test("explicit roleDef.timeout is always honored (no widening)", () => {
224
- assert.equal(effectiveTimeout(role(["read", "delegate"], 300), 600), 300);
257
+ test("explicit role timeout is honored", () => {
258
+ assert.equal(effectiveTimeout(role(["read", "delegate"], 300)), 300);
225
259
  });
226
- test("explicit timeout on non-delegate also honored", () => {
227
- assert.equal(effectiveTimeout(role(["read"]), 600), 600);
260
+ test("negative and non-finite values normalize to unlimited", () => {
261
+ assert.equal(effectiveTimeout(role(["read"], -1)), 0);
262
+ assert.equal(effectiveTimeout(role(["read"], Number.POSITIVE_INFINITY)), 0);
228
263
  });
229
264
  });
230
265
 
package/src/utils.ts CHANGED
@@ -219,11 +219,24 @@ export function previewArgs(args: Record<string, unknown>): string {
219
219
  return argsStr.length > 50 ? argsStr.slice(0, 50) + "..." : argsStr;
220
220
  }
221
221
 
222
+ // ── Numeric configuration ─────────────────────────────────────
223
+
224
+ /** Normalize a finite numeric limit: invalid values use the default; negatives become 0 (unlimited). */
225
+ export function normalizeNonNegativeNumber(value: unknown, fallback: number): number {
226
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
227
+ return Math.max(0, value);
228
+ }
229
+
230
+ /** Normalize a count limit to a non-negative integer. */
231
+ export function normalizeNonNegativeInteger(value: unknown, fallback: number): number {
232
+ return Math.floor(normalizeNonNegativeNumber(value, fallback));
233
+ }
234
+
222
235
  // ── Concurrency gate ───────────────────────────────────────────────
223
236
 
224
237
  /**
225
238
  * Promise-based semaphore capping concurrent subagent spawns.
226
- * acquire() resolves immediately while under the limit, otherwise queues.
239
+ * A max of 0 means unlimited concurrency, so acquire() never queues.
227
240
  * Pass an AbortSignal to cancel while waiting (rejects and removes the waiter).
228
241
  */
229
242
  export class AsyncSemaphore {
@@ -231,10 +244,16 @@ export class AsyncSemaphore {
231
244
  private waiters: Array<() => void> = [];
232
245
  private max: number;
233
246
  constructor(max: number) {
234
- this.max = max;
247
+ this.max = normalizeNonNegativeInteger(max, 0);
248
+ }
249
+ get isLimited(): boolean {
250
+ return this.max > 0;
251
+ }
252
+ get isAtCapacity(): boolean {
253
+ return this.isLimited && this.active >= this.max;
235
254
  }
236
255
  async acquire(signal?: AbortSignal): Promise<void> {
237
- if (this.active < this.max) {
256
+ if (!this.isAtCapacity) {
238
257
  this.active++;
239
258
  return;
240
259
  }
@@ -262,6 +281,7 @@ export class AsyncSemaphore {
262
281
  }
263
282
  release(): void {
264
283
  this.active = Math.max(0, this.active - 1);
284
+ if (!this.isLimited) return;
265
285
  const next = this.waiters.shift();
266
286
  if (next) next();
267
287
  }
@@ -271,12 +291,10 @@ export class AsyncSemaphore {
271
291
 
272
292
  /**
273
293
  * Effective per-role timeout in SECONDS (convert to ms at the spawn boundary).
274
- * No widening for delegate-capable roles: the parent's active-time clock
275
- * pauses while the child is inside a nested `delegate` call, so the base
276
- * budget is already enough. An explicit roleDef.timeout always wins.
294
+ * `0` or unset means unlimited; non-finite and negative values normalize to 0.
277
295
  */
278
- export function effectiveTimeout(roleDef: SubagentRole, baseTimeoutSec: number): number {
279
- return roleDef.timeout ?? baseTimeoutSec;
296
+ export function effectiveTimeout(roleDef: SubagentRole): number {
297
+ return normalizeNonNegativeNumber(roleDef.timeout, 0);
280
298
  }
281
299
 
282
300
  // ── Output truncation ────────────────────────────────────────