@tekyzinc/gsd-t 4.0.20 → 4.0.21

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/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [4.0.21] - 2026-06-02 (M74 Adaptive Rate-Limit Throttle — patch)
6
+
7
+ ### Added — the scan throttle now self-lowers on a rate limit instead of failing
8
+
9
+ M73's fixed 10-permit gate prevents the all-at-once stampede, but couldn't react if a rate limit still occurred. M74 makes the gate ADAPTIVE: on a rate-limit error (`isRateLimit` matches "temporarily limiting requests", 429, overloaded, etc.) `gatedAgent` lowers the global ceiling by 1 (10→9→8…, floor `MIN_CONCURRENT=4`), backs off (2s/4s/6s), and RETRIES the same agent (up to 4 attempts) — so a transient rate limit throttles the run down rather than failing it. After 8 clean completions the ceiling nudges back up toward 10. A non-rate-limit error is not retried (bubbles up normally).
10
+
11
+ - `templates/workflows/gsd-t-scan.workflow.js`: `makeAdaptiveSemaphore` (shrinkable/recoverable ceiling, never yanks in-flight permits), rate-limit-aware `gatedAgent` with backoff+retry, `isRateLimit`, runtime `sleep`.
12
+ - `test/m74-adaptive-throttle.test.js`: +5 tests (rate-limit detection incl. the real server message; floor/recovery bounds; lowered-ceiling stops granting until in-use drops; **all work completes despite 5 injected rate limits, zero failures**).
13
+
14
+ Verified by 3 real sandbox diagnostics: `setTimeout` resolves in the sandbox (backoff is real); the adaptive gate lowered 10→5 under injected rate limits and completed all 12 items with 0 errors.
15
+
16
+ Suite: 1302 pass / 0 fail / 4 skip — zero regressions.
17
+
5
18
  ## [4.0.20] - 2026-06-02 (M73 Scan Concurrency Throttle — patch)
6
19
 
7
20
  ### Fixed — unthrottled fan-out triggered an API rate limit that wiped a whole scan
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "4.0.20",
3
+ "version": "4.0.21",
4
4
  "description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
5
5
  "author": "Tekyz, Inc.",
6
6
  "license": "MIT",
@@ -294,28 +294,77 @@ const MAX_CONCURRENT = 10;
294
294
 
295
295
  // Minimal counting semaphore: acquire() resolves when a permit is free; release()
296
296
  // hands the permit to the next waiter (FIFO).
297
- function makeSemaphore(permits) {
298
- let avail = permits;
297
+ // M74: ADAPTIVE semaphore — the ceiling can shrink (on a rate limit) and recover.
298
+ // `inUse` = permits currently held; a permit is grantable only while inUse < ceiling.
299
+ // Lowering the ceiling doesn't yank in-flight permits; it just stops granting new
300
+ // ones until enough release that inUse drops below the new ceiling.
301
+ const MIN_CONCURRENT = 4; // never throttle below this — keeps the run moving
302
+ function makeAdaptiveSemaphore(initial) {
303
+ let ceiling = initial;
304
+ let inUse = 0;
299
305
  const waiters = [];
306
+ function pump() {
307
+ while (inUse < ceiling && waiters.length) { inUse++; waiters.shift()(); }
308
+ }
300
309
  return {
301
310
  async acquire() {
302
- if (avail > 0) { avail--; return; }
303
- await new Promise((res) => waiters.push(res));
304
- // resumed: a release() handed us the permit (avail already decremented there).
311
+ if (inUse < ceiling) { inUse++; return; }
312
+ await new Promise((res) => waiters.push(res)); // pump() increments inUse on grant
313
+ },
314
+ release() { inUse--; pump(); },
315
+ lower() { // shrink ceiling by 1 on a rate limit (floor at MIN_CONCURRENT)
316
+ if (ceiling > MIN_CONCURRENT) { ceiling--; return true; }
317
+ return false;
305
318
  },
306
- release() {
307
- const next = waiters.shift();
308
- if (next) next(); // hand permit directly to the next waiter
309
- else avail++; // no waiter: return permit to the pool
319
+ raise() { // gentle recovery toward the initial ceiling after sustained success
320
+ if (ceiling < initial) { ceiling++; pump(); return true; }
321
+ return false;
310
322
  },
323
+ get ceiling() { return ceiling; },
311
324
  };
312
325
  }
313
- const gate = makeSemaphore(MAX_CONCURRENT);
326
+ const gate = makeAdaptiveSemaphore(MAX_CONCURRENT);
327
+
328
+ function isRateLimit(err) {
329
+ const s = String((err && (err.message || err)) || "").toLowerCase();
330
+ return /rate.?limit|temporarily limiting|429|overloaded|too many requests|capacity/.test(s);
331
+ }
332
+
333
+ // M74: gatedAgent now reacts to rate limits in REAL TIME — on a rate-limit error it
334
+ // lowers the global ceiling (10→9→8…, floor MIN_CONCURRENT), backs off, and retries
335
+ // the SAME agent (up to a few times) instead of letting it fail. After a streak of
336
+ // clean completions it nudges the ceiling back up. This means a transient rate limit
337
+ // throttles the run down automatically rather than failing it (the v4.0.19 wipeout).
338
+ let _cleanStreak = 0;
314
339
  async function gatedAgent(prompt, opts) {
315
340
  await gate.acquire();
316
- try { return await agent(prompt, opts); }
317
- finally { gate.release(); }
341
+ try {
342
+ for (let attempt = 1; attempt <= 4; attempt++) {
343
+ try {
344
+ const r = await agent(prompt, opts);
345
+ // success: nudge the ceiling back up after every 8 clean completions.
346
+ if (++_cleanStreak >= 8) { _cleanStreak = 0; if (gate.raise()) log(`↑ throttle recovered to ${gate.ceiling} concurrent (clean streak)`); }
347
+ return r;
348
+ } catch (e) {
349
+ if (isRateLimit(e) && attempt < 4) {
350
+ _cleanStreak = 0;
351
+ const lowered = gate.lower();
352
+ const backoffMs = 2000 * attempt; // 2s, 4s, 6s
353
+ log(`⚠ rate limit hit — ${lowered ? `throttling down to ${gate.ceiling} concurrent` : `already at floor ${gate.ceiling}`}; backing off ${backoffMs}ms then retry ${attempt + 1}/4 (${(opts && opts.label) || "agent"})`);
354
+ await sleep(backoffMs);
355
+ continue;
356
+ }
357
+ throw e; // non-rate-limit error, or out of retries → bubble up
358
+ }
359
+ }
360
+ } finally {
361
+ gate.release();
362
+ }
318
363
  }
364
+ // Backoff sleep. setTimeout is available in the Workflow sandbox (verified by a
365
+ // real sandbox probe, run wf_7e90a974 — NOT assumed; Date.now/Math.random ARE banned
366
+ // but setTimeout is not). Used only for rate-limit backoff between retries.
367
+ function sleep(ms) { return new Promise((res) => setTimeout(res, ms)); }
319
368
 
320
369
  async function runFinder(slice) {
321
370
  // up to 2 attempts; a null/invalid (non-array findings) result counts as a drop.