@tekyzinc/gsd-t 4.0.19 → 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,32 @@
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
+
18
+ ## [4.0.20] - 2026-06-02 (M73 Scan Concurrency Throttle — patch)
19
+
20
+ ### Fixed — unthrottled fan-out triggered an API rate limit that wiped a whole scan
21
+
22
+ A v4.0.19 Hilo run produced an EMPTY register (0 findings) — not a workflow-logic bug: the scan fanned out 29 finders + their verifiers ALL AT ONCE (~58 concurrent Sonnet agents), hit a server-side API rate limit ("temporarily limiting requests · Rate limited"), and all 58 agents errored out empty. (M72's coverage-honesty correctly flagged it: 0/29, pointed to the prior 133-item register — no false "complete".)
23
+
24
+ - `templates/workflows/gsd-t-scan.workflow.js`: added a single GLOBAL counting semaphore (`makeSemaphore`) of 10 permits. EVERY finder + verify agent acquires a permit before running and releases after (`gatedAgent`); freed permits are handed FIFO to the next waiter. All slices + findings still fan out at once, but total in-flight never exceeds 10 — a shared worker-pool: the instant any agent finishes, the next queued one starts, so throughput stays maximal at a safe ceiling. (Finders + verifiers are Sonnet, fine at 10; the lone Opus synthesis runs after, ungated.)
25
+ - This replaces the initial batched approach (which left worker slots idle while a slice serialized its verifies) with a global queue — better throughput AND simpler.
26
+
27
+ Verified by two real sandbox diagnostics: a 30-agent `mapLimit` probe and a 56-agent (8 finders × 6 verifies, fanned out at once) semaphore probe — both measured peakConcurrency = 10, never exceeded.
28
+
29
+ Suite: 1297 pass / 0 fail / 4 skip — zero regressions.
30
+
5
31
  ## [4.0.19] - 2026-06-02 (M72 Scan Dropped-Slice Recovery + Coverage Honesty — patch)
6
32
 
7
33
  ### Fixed — a scan that under-covers no longer presents partial results as complete
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "4.0.19",
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",
@@ -281,11 +281,96 @@ function finderPrompt(slice) {
281
281
  `CRITICAL: you MUST return a JSON object matching the schema (slice + findings array) as your FINAL output — even if findings is empty. Do not end without the structured result.`,
282
282
  ].filter(Boolean).join("\n");
283
283
  }
284
+ // M73: GLOBAL CONCURRENCY GATE (shared-worker-pool model). The v4.0.19 Hilo run
285
+ // fanned out 29 finders + their verifiers ALL AT ONCE → ~58 concurrent Sonnet agents
286
+ // → server-side API rate limit → all errored empty → 0 findings. Rather than batch
287
+ // per-slice (which leaves slots idle while a slice serializes its verifies), use ONE
288
+ // global semaphore of MAX_CONCURRENT permits that EVERY agent call (finder OR verify,
289
+ // from any slice) must acquire. Work fans out naturally; the gate alone enforces the
290
+ // cap. The instant any agent finishes, the next queued one starts → always ≈10 in
291
+ // flight while work remains = max throughput at a safe ceiling. (All gated agents are
292
+ // Sonnet; the lone Opus synthesis runs after, ungated.)
293
+ const MAX_CONCURRENT = 10;
294
+
295
+ // Minimal counting semaphore: acquire() resolves when a permit is free; release()
296
+ // hands the permit to the next waiter (FIFO).
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;
305
+ const waiters = [];
306
+ function pump() {
307
+ while (inUse < ceiling && waiters.length) { inUse++; waiters.shift()(); }
308
+ }
309
+ return {
310
+ async acquire() {
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;
318
+ },
319
+ raise() { // gentle recovery toward the initial ceiling after sustained success
320
+ if (ceiling < initial) { ceiling++; pump(); return true; }
321
+ return false;
322
+ },
323
+ get ceiling() { return ceiling; },
324
+ };
325
+ }
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;
339
+ async function gatedAgent(prompt, opts) {
340
+ await gate.acquire();
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
+ }
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)); }
368
+
284
369
  async function runFinder(slice) {
285
370
  // up to 2 attempts; a null/invalid (non-array findings) result counts as a drop.
286
371
  for (let attempt = 1; attempt <= 2; attempt++) {
287
372
  try {
288
- const r = await agent(finderPrompt(slice), {
373
+ const r = await gatedAgent(finderPrompt(slice), {
289
374
  label: attempt === 1 ? `find:${slice.key}` : `find:${slice.key} (retry)`,
290
375
  phase: "Deep Scan", schema: FINDER_SCHEMA, model: "sonnet",
291
376
  });
@@ -298,40 +383,43 @@ async function runFinder(slice) {
298
383
  return null; // both attempts failed → dropped slice
299
384
  }
300
385
 
301
- const sliceResults = await pipeline(
302
- slices,
303
- (slice) => runFinder(slice),
304
- async (finderResult, originalItem) => {
305
- const slice = originalItem || {};
306
- const sliceKey = slice.key || (finderResult && finderResult.slice) || "unknown-slice";
307
- // M72: distinguish a FAILED finder (null after retries) from a genuinely-clean slice.
308
- if (!finderResult || !Array.isArray(finderResult.findings)) {
309
- return { slice: sliceKey, findings: [], failed: true };
310
- }
311
- if (verifyMode === "none" || finderResult.findings.length === 0) {
312
- return { slice: sliceKey, findings: finderResult.findings || [], failed: false };
313
- }
314
- const verified = await parallel(
315
- finderResult.findings.map((f) => async () => {
316
- try {
317
- const v = await agent(
318
- [
319
- `You are a VERIFIER for one tech-debt finding in \`${projectDir}\`. Confirm it against the ACTUAL code (open the referenced files with Read) — do not trust the finder.`,
320
- `Finding: ${JSON.stringify(f)}`,
321
- `confirmed=true only if the defect genuinely exists. If misread → verdict="false-positive". If real but wrong severity → set correctedSeverity. If real but underspecified → verdict="needs-detail" (kept). Return JSON per the schema.`,
322
- ].join("\n"),
323
- { label: `verify:${sliceKey}`, phase: "Deep Scan", schema: VERIFY_SCHEMA, model: "sonnet" }
324
- );
325
- if (!v || v.verdict === "false-positive" || v.confirmed === false) return null;
326
- return { ...f, severity: v.correctedSeverity || f.severity, _verify: v.verdict };
327
- } catch (e) {
328
- return { ...f, _verify: "verify-errored" };
329
- }
330
- })
331
- );
332
- return { slice: sliceKey, findings: verified.filter(Boolean), failed: false };
386
+ async function scanSlice(slice) {
387
+ const sliceKey = slice.key || "unknown-slice";
388
+ const finderResult = await runFinder(slice);
389
+ // M72: distinguish a FAILED finder (null after retries) from a genuinely-clean slice.
390
+ if (!finderResult || !Array.isArray(finderResult.findings)) {
391
+ return { slice: sliceKey, findings: [], failed: true };
333
392
  }
334
- );
393
+ if (verifyMode === "none" || finderResult.findings.length === 0) {
394
+ return { slice: sliceKey, findings: finderResult.findings || [], failed: false };
395
+ }
396
+ // Fan out ALL verifies for this slice — the global gate (not a per-slice limit)
397
+ // bounds total in-flight, so this is safe AND keeps every worker slot busy.
398
+ const verified = await parallel(
399
+ finderResult.findings.map((f) => async () => {
400
+ try {
401
+ const v = await gatedAgent(
402
+ [
403
+ `You are a VERIFIER for one tech-debt finding in \`${projectDir}\`. Confirm it against the ACTUAL code (open the referenced files with Read) — do not trust the finder.`,
404
+ `Finding: ${JSON.stringify(f)}`,
405
+ `confirmed=true only if the defect genuinely exists. If misread → verdict="false-positive". If real but wrong severity → set correctedSeverity. If real but underspecified → verdict="needs-detail" (kept). Return JSON per the schema.`,
406
+ ].join("\n"),
407
+ { label: `verify:${sliceKey}`, phase: "Deep Scan", schema: VERIFY_SCHEMA, model: "sonnet" }
408
+ );
409
+ if (!v || v.verdict === "false-positive" || v.confirmed === false) return null;
410
+ return { ...f, severity: v.correctedSeverity || f.severity, _verify: v.verdict };
411
+ } catch (e) {
412
+ return { ...f, _verify: "verify-errored" };
413
+ }
414
+ })
415
+ );
416
+ return { slice: sliceKey, findings: verified.filter(Boolean), failed: false };
417
+ }
418
+
419
+ // Fan out ALL slices at once — every finder + verify acquires the shared gate, so
420
+ // total in-flight never exceeds MAX_CONCURRENT regardless of slice/finding counts.
421
+ log(`deep scan: ${slices.length} slices via a shared ${MAX_CONCURRENT}-permit gate (Sonnet finders+verifiers; max throughput at a safe ceiling)`);
422
+ const sliceResults = await parallel(slices.map((slice) => () => scanSlice(slice)));
335
423
 
336
424
  // M72: coverage accounting — a dropped pipeline result (null) OR a failed:true slice
337
425
  // is a COVERAGE GAP. Surface it deterministically; never present partial as complete.