@goodandready/dsh-key-rotation 0.8.87 → 0.8.88
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 +8 -0
- package/lib/index.js +8 -0
- package/lib/pool-builder.js +16 -0
- package/lib/resolver.js +3 -0
- package/lib/rotate.js +3 -0
- package/lib/subagent-partitions.js +163 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.88 (2026-11-03)
|
|
4
|
+
|
|
5
|
+
### Subagent Concurrency Partitions & Task Isolation (Block 15 Release)
|
|
6
|
+
- **Subagent Partitions Module (Issue #575)**: Introduced `src/subagent-partitions.js` to detect child tasks and background subagents (`extractSubagentContext`) and partition pool capacity (`partitionPoolKeys`).
|
|
7
|
+
- **Primary Chat Immunity**: Protects main user conversations from rate limit spikes and concurrency starvation caused by parallel background tasks by reserving dedicated subagent keys (`subagentReservedKeysCount`) or applying isolated partitioning (`subagentPartitionStrategy: 'isolated' | 'shared-burst'`).
|
|
8
|
+
- **Per-Key Subagent Concurrency Caps**: Implemented `subagentMaxConcurrencyPerKey` (default: 2) to cap parallel subagent calls per key, preventing sudden RPM/TPM exhaustion.
|
|
9
|
+
- **Config & Schemastery Support**: Added `subagentPartitionsEnabled`, `subagentReservedKeysCount`, `subagentPartitionStrategy`, and `subagentMaxConcurrencyPerKey` across Schemastery configuration, pool builders, and TypeScript types.
|
|
10
|
+
|
|
3
11
|
## 0.8.87 (2026-11-03)
|
|
4
12
|
|
|
5
13
|
### Cache-Affinity Router for Hot Prompt Cache (Block 14 Release)
|
package/lib/index.js
CHANGED
|
@@ -146,6 +146,10 @@ const SettingsSchema = Schema.object({
|
|
|
146
146
|
cacheAffinityEnabled: Schema.boolean().default(true),
|
|
147
147
|
cacheAffinityTtlMs: Schema.number().default(300000),
|
|
148
148
|
cacheAffinityPrefixChars: Schema.number().default(2048),
|
|
149
|
+
subagentPartitionsEnabled: Schema.boolean().default(true),
|
|
150
|
+
subagentReservedKeysCount: Schema.number().default(0),
|
|
151
|
+
subagentPartitionStrategy: Schema.union(['shared-burst', 'isolated']).default('shared-burst'),
|
|
152
|
+
subagentMaxConcurrencyPerKey: Schema.number().default(2),
|
|
149
153
|
providers: Schema.array(Schema.object({
|
|
150
154
|
provider: Schema.string().required(),
|
|
151
155
|
keys: Schema.array(Schema.string()).default([]),
|
|
@@ -184,6 +188,10 @@ const SettingsSchema = Schema.object({
|
|
|
184
188
|
cacheAffinityEnabled: Schema.boolean(),
|
|
185
189
|
cacheAffinityTtlMs: Schema.number(),
|
|
186
190
|
cacheAffinityPrefixChars: Schema.number(),
|
|
191
|
+
subagentPartitionsEnabled: Schema.boolean(),
|
|
192
|
+
subagentReservedKeysCount: Schema.number(),
|
|
193
|
+
subagentPartitionStrategy: Schema.union(['shared-burst', 'isolated']),
|
|
194
|
+
subagentMaxConcurrencyPerKey: Schema.number(),
|
|
187
195
|
})).default([...DEFAULT_PROVIDERS]),
|
|
188
196
|
});
|
|
189
197
|
|
package/lib/pool-builder.js
CHANGED
|
@@ -50,6 +50,10 @@ export function buildPoolItem({
|
|
|
50
50
|
cacheAffinityEnabled = true,
|
|
51
51
|
cacheAffinityTtlMs = 300000,
|
|
52
52
|
cacheAffinityPrefixChars = 2048,
|
|
53
|
+
subagentPartitionsEnabled = true,
|
|
54
|
+
subagentReservedKeysCount = 0,
|
|
55
|
+
subagentPartitionStrategy = 'shared-burst',
|
|
56
|
+
subagentMaxConcurrencyPerKey = 2,
|
|
53
57
|
}) {
|
|
54
58
|
const seen = new Set();
|
|
55
59
|
const indexed = (Array.isArray(keys) ? keys : []).map((key, index) => ({ ref: typeof key === 'string' ? key.trim() : '', index }))
|
|
@@ -169,6 +173,10 @@ const weightsMap = {};
|
|
|
169
173
|
cacheAffinityEnabled: cacheAffinityEnabled ?? true,
|
|
170
174
|
cacheAffinityTtlMs: typeof cacheAffinityTtlMs === 'number' && Number.isFinite(cacheAffinityTtlMs) && cacheAffinityTtlMs > 0 ? Math.floor(cacheAffinityTtlMs) : 300000,
|
|
171
175
|
cacheAffinityPrefixChars: typeof cacheAffinityPrefixChars === 'number' && Number.isFinite(cacheAffinityPrefixChars) && cacheAffinityPrefixChars > 0 ? Math.floor(cacheAffinityPrefixChars) : 2048,
|
|
176
|
+
subagentPartitionsEnabled: subagentPartitionsEnabled ?? true,
|
|
177
|
+
subagentReservedKeysCount: typeof subagentReservedKeysCount === 'number' && Number.isFinite(subagentReservedKeysCount) && subagentReservedKeysCount >= 0 ? Math.floor(subagentReservedKeysCount) : 0,
|
|
178
|
+
subagentPartitionStrategy: subagentPartitionStrategy === 'isolated' ? 'isolated' : 'shared-burst',
|
|
179
|
+
subagentMaxConcurrencyPerKey: typeof subagentMaxConcurrencyPerKey === 'number' && Number.isFinite(subagentMaxConcurrencyPerKey) && subagentMaxConcurrencyPerKey > 0 ? Math.floor(subagentMaxConcurrencyPerKey) : 2,
|
|
172
180
|
preemptiveRotationEnabled: preemptiveRotationEnabled ?? true,
|
|
173
181
|
preemptiveSafetyMarginTokens: typeof preemptiveSafetyMarginTokens === 'number' && Number.isFinite(preemptiveSafetyMarginTokens) && preemptiveSafetyMarginTokens >= 0 ? Math.floor(preemptiveSafetyMarginTokens) : 4000,
|
|
174
182
|
preemptiveSafetyMarginRequests: typeof preemptiveSafetyMarginRequests === 'number' && Number.isFinite(preemptiveSafetyMarginRequests) && preemptiveSafetyMarginRequests >= 0 ? Math.floor(preemptiveSafetyMarginRequests) : 2,
|
|
@@ -228,6 +236,10 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
228
236
|
cacheAffinityEnabled: p.cacheAffinityEnabled ?? cfg.cacheAffinityEnabled ?? true,
|
|
229
237
|
cacheAffinityTtlMs: p.cacheAffinityTtlMs ?? cfg.cacheAffinityTtlMs ?? 300000,
|
|
230
238
|
cacheAffinityPrefixChars: p.cacheAffinityPrefixChars ?? cfg.cacheAffinityPrefixChars ?? 2048,
|
|
239
|
+
subagentPartitionsEnabled: p.subagentPartitionsEnabled ?? cfg.subagentPartitionsEnabled ?? true,
|
|
240
|
+
subagentReservedKeysCount: p.subagentReservedKeysCount ?? cfg.subagentReservedKeysCount ?? 0,
|
|
241
|
+
subagentPartitionStrategy: p.subagentPartitionStrategy ?? cfg.subagentPartitionStrategy ?? 'shared-burst',
|
|
242
|
+
subagentMaxConcurrencyPerKey: p.subagentMaxConcurrencyPerKey ?? cfg.subagentMaxConcurrencyPerKey ?? 2,
|
|
231
243
|
preemptiveRotationEnabled: p.preemptiveRotationEnabled ?? cfg.preemptiveRotationEnabled ?? true,
|
|
232
244
|
preemptiveSafetyMarginTokens: p.preemptiveSafetyMarginTokens ?? cfg.preemptiveSafetyMarginTokens ?? 4000,
|
|
233
245
|
preemptiveSafetyMarginRequests: p.preemptiveSafetyMarginRequests ?? cfg.preemptiveSafetyMarginRequests ?? 2,
|
|
@@ -272,6 +284,10 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
|
|
|
272
284
|
cacheAffinityEnabled: mp.cacheAffinityEnabled ?? p.cacheAffinityEnabled ?? cfg.cacheAffinityEnabled ?? true,
|
|
273
285
|
cacheAffinityTtlMs: mp.cacheAffinityTtlMs ?? p.cacheAffinityTtlMs ?? cfg.cacheAffinityTtlMs ?? 300000,
|
|
274
286
|
cacheAffinityPrefixChars: mp.cacheAffinityPrefixChars ?? p.cacheAffinityPrefixChars ?? cfg.cacheAffinityPrefixChars ?? 2048,
|
|
287
|
+
subagentPartitionsEnabled: mp.subagentPartitionsEnabled ?? p.subagentPartitionsEnabled ?? cfg.subagentPartitionsEnabled ?? true,
|
|
288
|
+
subagentReservedKeysCount: mp.subagentReservedKeysCount ?? p.subagentReservedKeysCount ?? cfg.subagentReservedKeysCount ?? 0,
|
|
289
|
+
subagentPartitionStrategy: mp.subagentPartitionStrategy ?? p.subagentPartitionStrategy ?? cfg.subagentPartitionStrategy ?? 'shared-burst',
|
|
290
|
+
subagentMaxConcurrencyPerKey: mp.subagentMaxConcurrencyPerKey ?? p.subagentMaxConcurrencyPerKey ?? cfg.subagentMaxConcurrencyPerKey ?? 2,
|
|
275
291
|
preemptiveRotationEnabled: mp.preemptiveRotationEnabled ?? p.preemptiveRotationEnabled ?? cfg.preemptiveRotationEnabled ?? true,
|
|
276
292
|
preemptiveSafetyMarginTokens: mp.preemptiveSafetyMarginTokens ?? p.preemptiveSafetyMarginTokens ?? cfg.preemptiveSafetyMarginTokens ?? 4000,
|
|
277
293
|
preemptiveSafetyMarginRequests: mp.preemptiveSafetyMarginRequests ?? p.preemptiveSafetyMarginRequests ?? cfg.preemptiveSafetyMarginRequests ?? 2,
|
package/lib/resolver.js
CHANGED
|
@@ -13,6 +13,7 @@ import { envValue, sortAttemptList, isKeyPaused, isKeyRevoked, isKeyBroken, cost
|
|
|
13
13
|
import { decryptSecret } from './crypto-storage.js';
|
|
14
14
|
import { applyPreemptiveYield } from './preemptive-yield.js';
|
|
15
15
|
import { extractPromptPrefixKey, applyCacheAffinity } from './cache-affinity.js';
|
|
16
|
+
import { partitionPoolKeys } from './subagent-partitions.js';
|
|
16
17
|
import { resolveRefPool, isQuotaManagedRequest, providerOfBase, basePoolForBase } from './pool-index.js';
|
|
17
18
|
import {
|
|
18
19
|
isModelQuotaAvailable,
|
|
@@ -139,6 +140,8 @@ export function createResolver({
|
|
|
139
140
|
if (cacheKey) {
|
|
140
141
|
list = applyCacheAffinity(list, pool, { cacheKey, now: Date.now() });
|
|
141
142
|
}
|
|
143
|
+
// Issue #575: Subagent Concurrency Partitions
|
|
144
|
+
list = partitionPoolKeys(list, pool, { reqOptions: resolveOpts, concurrencyTracker, now: Date.now() });
|
|
142
145
|
// Issue #571: Pre-emptive Token & Request Yield before 429
|
|
143
146
|
list = applyPreemptiveYield(list, pool, { now: Date.now() });
|
|
144
147
|
// Fail closed: when this request is managed by a model pool that has local
|
package/lib/rotate.js
CHANGED
|
@@ -29,6 +29,7 @@ import { recordStreamSuccessMetrics, preserveDispatchContext } from './stream-me
|
|
|
29
29
|
import { withStreamWatchdog, StreamStallError } from './stream-watchdog.js';
|
|
30
30
|
import { applyPreemptiveYield } from './preemptive-yield.js';
|
|
31
31
|
import { extractPromptPrefixKey, applyCacheAffinity } from './cache-affinity.js';
|
|
32
|
+
import { partitionPoolKeys } from './subagent-partitions.js';
|
|
32
33
|
import { penalizeKey } from './rotate-penalty.js';
|
|
33
34
|
import { recordPromptCache } from './prompt-cache.js';
|
|
34
35
|
import { recordRequestOutcome, recordFailureCategory } from './ops-analytics.js';
|
|
@@ -210,6 +211,8 @@ export function createRotate(deps) {
|
|
|
210
211
|
if (reqStore.cacheKey) {
|
|
211
212
|
attemptList = applyCacheAffinity(attemptList, pool, { cacheKey: reqStore.cacheKey, now: wallNow, trace, pushEvent });
|
|
212
213
|
}
|
|
214
|
+
// Issue #575: Subagent Concurrency Partitions
|
|
215
|
+
attemptList = partitionPoolKeys(attemptList, pool, { reqOptions: options, concurrencyTracker, now: wallNow, trace });
|
|
213
216
|
|
|
214
217
|
// Issue #571: Pre-emptive Token & Request Yield before 429
|
|
215
218
|
attemptList = applyPreemptiveYield(attemptList, pool, {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// src/subagent-partitions.js — Subagent Concurrency Partitions & Isolation (#575).
|
|
2
|
+
// Pure module: zero runtime dependencies, strictly < 600 lines.
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_SUBAGENT_MAX_CONCURRENCY_PER_KEY = 2;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extracts subagent or background task context from request options and metadata.
|
|
8
|
+
* @param {object} options
|
|
9
|
+
* @returns {{ isSubagent: boolean, subagentId?: string, parentSessionId?: string }}
|
|
10
|
+
*/
|
|
11
|
+
export function extractSubagentContext(options) {
|
|
12
|
+
if (!options || typeof options !== 'object') return { isSubagent: false };
|
|
13
|
+
const meta = options.metadata || options.meta || {};
|
|
14
|
+
const parentSessionId = typeof meta.parentSessionId === 'string' && meta.parentSessionId.trim() ? meta.parentSessionId.trim() : undefined;
|
|
15
|
+
|
|
16
|
+
let isSubagent = false;
|
|
17
|
+
let subagentId = undefined;
|
|
18
|
+
|
|
19
|
+
if (options.isSubagent === true || meta.isSubagent === true) {
|
|
20
|
+
isSubagent = true;
|
|
21
|
+
subagentId = options.subagentId || meta.subagentId;
|
|
22
|
+
} else if (typeof options.subagentId === 'string' && options.subagentId.trim()) {
|
|
23
|
+
isSubagent = true;
|
|
24
|
+
subagentId = options.subagentId.trim();
|
|
25
|
+
} else if (typeof meta.subagentId === 'string' && meta.subagentId.trim()) {
|
|
26
|
+
isSubagent = true;
|
|
27
|
+
subagentId = meta.subagentId.trim();
|
|
28
|
+
} else if (typeof options.subagent === 'string' && options.subagent.trim()) {
|
|
29
|
+
isSubagent = true;
|
|
30
|
+
subagentId = options.subagent.trim();
|
|
31
|
+
} else if (options.subagent === true) {
|
|
32
|
+
isSubagent = true;
|
|
33
|
+
} else if (parentSessionId) {
|
|
34
|
+
isSubagent = true;
|
|
35
|
+
subagentId = meta.childSessionId;
|
|
36
|
+
} else {
|
|
37
|
+
// Check sessionId prefixes
|
|
38
|
+
const sid = String(options.sessionId || options.session || options.chatId || options.conversationId || '').trim();
|
|
39
|
+
if (
|
|
40
|
+
sid.startsWith('subagent:') ||
|
|
41
|
+
sid.startsWith('subagent-') ||
|
|
42
|
+
sid.startsWith('subtask:') ||
|
|
43
|
+
sid.startsWith('subtask-') ||
|
|
44
|
+
sid.startsWith('agent:sub:')
|
|
45
|
+
) {
|
|
46
|
+
isSubagent = true;
|
|
47
|
+
subagentId = sid;
|
|
48
|
+
} else {
|
|
49
|
+
// Check tags
|
|
50
|
+
const tags = Array.isArray(options.tags) ? options.tags : (Array.isArray(meta.tags) ? meta.tags : []);
|
|
51
|
+
if (tags.some(t => typeof t === 'string' && (t === 'subagent' || t === 'subtask' || t === 'background'))) {
|
|
52
|
+
isSubagent = true;
|
|
53
|
+
subagentId = options.subagentId || meta.subagentId;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (isSubagent) {
|
|
59
|
+
const res = { isSubagent: true };
|
|
60
|
+
if (subagentId) res.subagentId = subagentId;
|
|
61
|
+
if (parentSessionId) res.parentSessionId = parentSessionId;
|
|
62
|
+
return res;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { isSubagent: false };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Partitions or reorders candidate keys according to subagent isolation rules.
|
|
70
|
+
* @param {string[]} attemptList
|
|
71
|
+
* @param {object} pool
|
|
72
|
+
* @param {object} [options]
|
|
73
|
+
* @returns {string[]}
|
|
74
|
+
*/
|
|
75
|
+
export function partitionPoolKeys(attemptList, pool, options = {}) {
|
|
76
|
+
if (!Array.isArray(attemptList) || attemptList.length <= 1 || !pool) {
|
|
77
|
+
return (attemptList ?? []).slice();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const enabled = options.enabled ?? pool.subagentPartitionsEnabled ?? true;
|
|
81
|
+
if (!enabled) return attemptList.slice();
|
|
82
|
+
|
|
83
|
+
const subCtx = options.subagentContext ?? extractSubagentContext(options.reqOptions);
|
|
84
|
+
const isSubagent = Boolean(subCtx.isSubagent);
|
|
85
|
+
const reservedCount = pool.subagentReservedKeysCount ?? 0;
|
|
86
|
+
const strategy = pool.subagentPartitionStrategy ?? 'shared-burst';
|
|
87
|
+
const labels = pool.labels || {};
|
|
88
|
+
|
|
89
|
+
const explicitSubagentRefs = new Set();
|
|
90
|
+
const explicitPrimaryRefs = new Set();
|
|
91
|
+
|
|
92
|
+
for (const [ref, label] of Object.entries(labels)) {
|
|
93
|
+
const lbl = String(label).toLowerCase();
|
|
94
|
+
if (lbl === 'subagent' || lbl === 'subtask' || lbl === 'worker') explicitSubagentRefs.add(ref);
|
|
95
|
+
if (lbl === 'primary' || lbl === 'main' || lbl === 'user') explicitPrimaryRefs.add(ref);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// If reservedCount > 0, reserve the last reservedCount keys of pool.refs if not already labelled primary
|
|
99
|
+
if (reservedCount > 0 && pool.refs && pool.refs.length > reservedCount) {
|
|
100
|
+
const reservedSlice = pool.refs.slice(pool.refs.length - reservedCount);
|
|
101
|
+
for (const r of reservedSlice) {
|
|
102
|
+
if (!explicitPrimaryRefs.has(r)) explicitSubagentRefs.add(r);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const hasPartitioning = explicitSubagentRefs.size > 0;
|
|
107
|
+
let list = attemptList.slice();
|
|
108
|
+
|
|
109
|
+
if (isSubagent) {
|
|
110
|
+
// SUBAGENT REQUEST
|
|
111
|
+
if (hasPartitioning) {
|
|
112
|
+
const dedicated = list.filter(r => explicitSubagentRefs.has(r));
|
|
113
|
+
const shared = list.filter(r => !explicitSubagentRefs.has(r));
|
|
114
|
+
|
|
115
|
+
if (strategy === 'isolated') {
|
|
116
|
+
if (dedicated.length > 0) list = dedicated;
|
|
117
|
+
} else {
|
|
118
|
+
list = dedicated.concat(shared);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
// PRIMARY / USER REQUEST
|
|
123
|
+
if (hasPartitioning) {
|
|
124
|
+
const primary = list.filter(r => !explicitSubagentRefs.has(r));
|
|
125
|
+
const subOnly = list.filter(r => explicitSubagentRefs.has(r));
|
|
126
|
+
|
|
127
|
+
if (primary.length > 0) {
|
|
128
|
+
list = strategy === 'isolated' ? primary : primary.concat(subOnly);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Apply subagent per-key concurrency cap
|
|
134
|
+
const tracker = options.concurrencyTracker;
|
|
135
|
+
const maxSubConcurrency = pool.subagentMaxConcurrencyPerKey ?? DEFAULT_SUBAGENT_MAX_CONCURRENCY_PER_KEY;
|
|
136
|
+
if (isSubagent && tracker && typeof tracker.getActive === 'function' && maxSubConcurrency > 0) {
|
|
137
|
+
const now = options.now ?? Date.now();
|
|
138
|
+
const withinCap = [];
|
|
139
|
+
const saturated = [];
|
|
140
|
+
for (const r of list) {
|
|
141
|
+
const active = tracker.getActive(r, now) ?? 0;
|
|
142
|
+
if (active >= maxSubConcurrency) {
|
|
143
|
+
saturated.push(r);
|
|
144
|
+
} else {
|
|
145
|
+
withinCap.push(r);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (withinCap.length > 0 && saturated.length > 0) {
|
|
149
|
+
list = withinCap.concat(saturated);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (options.trace && typeof options.trace.addStep === 'function' && list[0] !== attemptList[0]) {
|
|
154
|
+
options.trace.addStep(
|
|
155
|
+
list[0],
|
|
156
|
+
'PRIORITIZED',
|
|
157
|
+
'SUBAGENT_PARTITION',
|
|
158
|
+
isSubagent ? 'Subagent partition key allocated' : 'Primary chat partition prioritized'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return list;
|
|
163
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.88",
|
|
4
4
|
"packageManager": "pnpm@10.33.2",
|
|
5
5
|
"description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
|
|
6
6
|
"keywords": [
|