@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 +13 -0
- package/package.json +1 -1
- package/templates/workflows/gsd-t-scan.workflow.js +61 -12
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.
|
|
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
|
-
|
|
298
|
-
|
|
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 (
|
|
303
|
-
await new Promise((res) => waiters.push(res));
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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 =
|
|
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 {
|
|
317
|
-
|
|
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.
|