@goodandready/dsh-key-rotation 0.8.87 → 0.8.89

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
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.89 (2026-11-03)
4
+
5
+ ### Interactive Header Chip UI & In-Chat Key Controls (Block 16 Release)
6
+ - **Interactive Header Chip Component (Issue #577)**: Enhanced `KeyRotationHeaderChip` (`lib/client-src/60-header-chip.js`) into a rich, interactive control center directly in the DeepSeek Harness chat header.
7
+ - **In-Chat Key Pinning & Selection**: Added instant dropdown selection to pin (`pinnedRef`) any key in the pool or restore `Auto (Round-robin)` rotation without opening the full settings page.
8
+ - **Prompt Cache & Health Badges**: Displays live prompt cache hit counters (`⚡ {promptCacheHits}`) and active/pinned key tails (`...tail`) with real-time health indicator status.
9
+ - **Quick-Action API Pinning Support**: Extended `/dsh-key-rotation/quick-action` route with `action: 'pin'` and audit event emission (`KEY_PINNED` / `KEY_UNPINNED`).
10
+ - **Detailed Health Endpoint**: Enriched `/dsh-key-rotation/health` to expose pool key manifests, individual key statuses (`active`, `pinned`, `paused`, `cooling`), and prompt cache hit statistics.
11
+
12
+ ## 0.8.88 (2026-11-03)
13
+
14
+ ### Subagent Concurrency Partitions & Task Isolation (Block 15 Release)
15
+ - **Subagent Partitions Module (Issue #575)**: Introduced `src/subagent-partitions.js` to detect child tasks and background subagents (`extractSubagentContext`) and partition pool capacity (`partitionPoolKeys`).
16
+ - **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'`).
17
+ - **Per-Key Subagent Concurrency Caps**: Implemented `subagentMaxConcurrencyPerKey` (default: 2) to cap parallel subagent calls per key, preventing sudden RPM/TPM exhaustion.
18
+ - **Config & Schemastery Support**: Added `subagentPartitionsEnabled`, `subagentReservedKeysCount`, `subagentPartitionStrategy`, and `subagentMaxConcurrencyPerKey` across Schemastery configuration, pool builders, and TypeScript types.
19
+
3
20
  ## 0.8.87 (2026-11-03)
4
21
 
5
22
  ### Cache-Affinity Router for Hot Prompt Cache (Block 14 Release)
@@ -245,6 +245,11 @@
245
245
  quickResetTitle: 'Reset cooldowns and circuit breaker for active provider',
246
246
  quickPause: 'Pause',
247
247
  quickPauseTitle: 'Pause or resume active key',
248
+ quickPin: 'Pin',
249
+ quickPinTitle: 'Pin specific key or select Auto round-robin',
250
+ quickAuto: 'Auto (Round-robin)',
251
+ cacheHits: 'Cache: {n}',
252
+ pinnedBadge: 'pinned',
248
253
  exportConfig: 'Export Pools',
249
254
  importConfig: 'Import Pools',
250
255
  exportSafe: 'Masked (Safe for sharing)',
@@ -518,6 +523,11 @@
518
523
  quickResetTitle: '重置当前提供商的冷却时间与断路器',
519
524
  quickPause: '暂停',
520
525
  quickPauseTitle: '暂停或恢复当前正在使用的密钥',
526
+ quickPin: '固定',
527
+ quickPinTitle: '固定指定密钥或设为自动轮换',
528
+ quickAuto: '自动轮换',
529
+ cacheHits: '缓存: {n}',
530
+ pinnedBadge: '已固定',
521
531
  exportConfig: '导出密钥池',
522
532
  importConfig: '导入密钥池',
523
533
  exportSafe: '脱敏导出 (安全分享)',
@@ -1,5 +1,5 @@
1
- // (issue 201, #499) header chip: one dot + counts for all pools. Green = all healthy,
2
- // amber = some keys cooling, red = a pool fully exhausted. Click opens quick actions popover.
1
+ // (issue 201, #499, #577) header chip: one dot + counts for all pools. Green = all healthy,
2
+ // amber = some keys cooling, red = a pool fully exhausted. Click opens interactive quick actions popover.
3
3
  function KeyRotationHeaderChip(props) {
4
4
  const t = resolveT(props);
5
5
  const [snap, setSnap] = React.useState(null);
@@ -93,46 +93,101 @@
93
93
  <span style={{ fontSize: '9px', opacity: 0.6 }}>{open ? '▲' : '▼'}</span>
94
94
  </button>
95
95
  {open ? (
96
- <div className="krot-popover" style={{ minWidth: '220px' }}>
97
- <div className="krot-pop-title">{t('headerPoolsTitle')}</div>
96
+ <div className="krot-popover" style={{ minWidth: '260px' }}>
97
+ <div className="krot-pop-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
98
+ <span>{t('headerPoolsTitle')}</span>
99
+ {busy ? <span style={{ fontSize: '11px', opacity: 0.7 }}>...</span> : null}
100
+ </div>
98
101
  {!poolEntries.length ? (
99
102
  <div style={{ fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' }}>{t('noActivePools')}</div>
100
103
  ) : null}
101
104
  {poolEntries.map(([name, p]) => {
102
105
  const c = p.exhausted ? 'var(--dsw-alias-state-error-primary)' : (p.healthy < p.total ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)');
103
106
  const provName = p.providerName || name;
104
- const tail = p.lastUsedTail;
107
+ const activeTail = p.lastUsedTail;
108
+ const pinnedTail = p.pinnedTail;
109
+ const isPinned = Boolean(p.pinnedRef);
110
+ const keysList = Array.isArray(p.keys) ? p.keys : [];
111
+ const cacheHits = p.promptCacheHits || 0;
112
+
105
113
  return (
106
- <div key={name} className="krot-pop-row" style={{ display: 'flex', flexDirection: 'column', gap: '4px', padding: '6px 0', borderBottom: '1px solid var(--dsw-alias-border-subtle)' }}>
114
+ <div key={name} className="krot-pop-row" style={{ display: 'flex', flexDirection: 'column', gap: '5px', padding: '8px 0', borderBottom: '1px solid var(--dsw-alias-border-subtle)' }}>
107
115
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
108
- <div className="krot-pop-name">
116
+ <div className="krot-pop-name" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
109
117
  <span style={{ width: '7px', height: '7px', borderRadius: '50%', background: c, flex: 'none' }} />
110
- {name}
111
- {tail ? <span style={{ marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)', fontFeatureSettings: '"tnum"', fontFamily: 'monospace' }}>{`...${tail}`}</span> : null}
118
+ <strong style={{ fontSize: '12px' }}>{name}</strong>
112
119
  </div>
113
- <div className="krot-pop-count" style={{ color: c, fontWeight: 600 }}>{`${p.healthy}/${p.total}`}</div>
120
+ <div className="krot-pop-count" style={{ color: c, fontWeight: 600, fontSize: '11px' }}>{`${p.healthy}/${p.total}`}</div>
121
+ </div>
122
+
123
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' }}>
124
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
125
+ <span>{isPinned ? '📌' : '⚡'}</span>
126
+ <span style={{ fontFamily: 'monospace' }}>
127
+ {isPinned ? (pinnedTail ? `...${pinnedTail} (pinned)` : p.pinnedRef) : (activeTail ? `...${activeTail}` : 'auto')}
128
+ </span>
129
+ </span>
130
+ {cacheHits > 0 ? (
131
+ <span style={{ fontSize: '10px', padding: '1px 4px', borderRadius: '3px', background: 'var(--dsw-alias-bg-subtle, transparent)', color: 'var(--dsw-alias-state-success-primary)' }} title="Prompt cache hits">
132
+ {`⚡ ${cacheHits}`}
133
+ </span>
134
+ ) : null}
114
135
  </div>
115
- <div style={{ display: 'flex', gap: '4px', marginTop: '2px' }}>
136
+
137
+ {keysList.length > 1 ? (
138
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '2px' }}>
139
+ <select
140
+ disabled={busy}
141
+ value={p.pinnedRef || ''}
142
+ onChange={(e) => runAction('pin', provName, e.target.value)}
143
+ title={t('quickPinTitle')}
144
+ style={{
145
+ width: '100%',
146
+ fontSize: '11px',
147
+ padding: '3px 6px',
148
+ borderRadius: '4px',
149
+ border: '1px solid var(--dsw-alias-border-subtle)',
150
+ background: 'var(--dsw-alias-bg-subtle, transparent)',
151
+ color: 'inherit',
152
+ fontFamily: 'inherit',
153
+ cursor: 'pointer'
154
+ }}
155
+ >
156
+ <option value="">{`🔀 ${t('quickAuto')}`}</option>
157
+ {keysList.map((k) => {
158
+ const marker = k.pinned ? '📌 ' : (k.active ? '▶ ' : '');
159
+ const status = k.paused ? ' [paused]' : (k.cooling ? ' [cooling]' : '');
160
+ return (
161
+ <option key={k.ref} value={k.ref}>
162
+ {`${marker}...${k.tail || k.ref.slice(-4)}${status}`}
163
+ </option>
164
+ );
165
+ })}
166
+ </select>
167
+ </div>
168
+ ) : null}
169
+
170
+ <div style={{ display: 'flex', gap: '4px', marginTop: '3px' }}>
116
171
  <button
117
172
  type="button"
118
173
  disabled={busy}
119
174
  onClick={(e) => { e.stopPropagation(); runAction('skip', provName, p.lastUsed); }}
120
175
  title={t('quickSkipTitle')}
121
- style={{ padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }}
176
+ style={{ flex: 1, padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }}
122
177
  >{`⏭️ ${t('quickSkip')}`}</button>
123
178
  <button
124
179
  type="button"
125
180
  disabled={busy}
126
181
  onClick={(e) => { e.stopPropagation(); runAction('reset', provName, p.lastUsed); }}
127
182
  title={t('quickResetTitle')}
128
- style={{ padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }}
183
+ style={{ flex: 1, padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }}
129
184
  >{`🔄 ${t('quickReset')}`}</button>
130
185
  <button
131
186
  type="button"
132
187
  disabled={busy || !p.lastUsed}
133
188
  onClick={(e) => { e.stopPropagation(); runAction('pause', provName, p.lastUsed); }}
134
189
  title={t('quickPauseTitle')}
135
- style={{ padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }}
190
+ style={{ flex: 1, padding: '2px 6px', fontSize: '11px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-subtle)', background: 'transparent', cursor: 'pointer', color: 'inherit' }}
136
191
  >{`⏸️ ${t('quickPause')}`}</button>
137
192
  </div>
138
193
  </div>
package/lib/client.js CHANGED
@@ -264,6 +264,11 @@ window.__ModuleLoader__.load({
264
264
  quickResetTitle: "Reset cooldowns and circuit breaker for active provider",
265
265
  quickPause: "Pause",
266
266
  quickPauseTitle: "Pause or resume active key",
267
+ quickPin: "Pin",
268
+ quickPinTitle: "Pin specific key or select Auto round-robin",
269
+ quickAuto: "Auto (Round-robin)",
270
+ cacheHits: "Cache: {n}",
271
+ pinnedBadge: "pinned",
267
272
  exportConfig: "Export Pools",
268
273
  importConfig: "Import Pools",
269
274
  exportSafe: "Masked (Safe for sharing)",
@@ -536,6 +541,11 @@ window.__ModuleLoader__.load({
536
541
  quickResetTitle: "重置当前提供商的冷却时间与断路器",
537
542
  quickPause: "暂停",
538
543
  quickPauseTitle: "暂停或恢复当前正在使用的密钥",
544
+ quickPin: "固定",
545
+ quickPinTitle: "固定指定密钥或设为自动轮换",
546
+ quickAuto: "自动轮换",
547
+ cacheHits: "缓存: {n}",
548
+ pinnedBadge: "已固定",
539
549
  exportConfig: "导出密钥池",
540
550
  importConfig: "导入密钥池",
541
551
  exportSafe: "脱敏导出 (安全分享)",
@@ -3833,11 +3843,40 @@ window.__ModuleLoader__.load({
3833
3843
  /* @__PURE__ */ React.createElement("span", { style: { width: "8px", height: "8px", borderRadius: "50%", background: color, flex: "none", boxShadow: `0 0 6px ${color}` } }),
3834
3844
  label,
3835
3845
  /* @__PURE__ */ React.createElement("span", { style: { fontSize: "9px", opacity: 0.6 } }, open ? "▲" : "▼")
3836
- ), open ? /* @__PURE__ */ React.createElement("div", { className: "krot-popover", style: { minWidth: "220px" } }, /* @__PURE__ */ React.createElement("div", { className: "krot-pop-title" }, t("headerPoolsTitle")), !poolEntries.length ? /* @__PURE__ */ React.createElement("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-tertiary)" } }, t("noActivePools")) : null, poolEntries.map(([name, p]) => {
3846
+ ), open ? /* @__PURE__ */ React.createElement("div", { className: "krot-popover", style: { minWidth: "260px" } }, /* @__PURE__ */ React.createElement("div", { className: "krot-pop-title", style: { display: "flex", justifyContent: "space-between", alignItems: "center" } }, /* @__PURE__ */ React.createElement("span", null, t("headerPoolsTitle")), busy ? /* @__PURE__ */ React.createElement("span", { style: { fontSize: "11px", opacity: 0.7 } }, "...") : null), !poolEntries.length ? /* @__PURE__ */ React.createElement("div", { style: { fontSize: "12px", color: "var(--dsw-alias-label-tertiary)" } }, t("noActivePools")) : null, poolEntries.map(([name, p]) => {
3837
3847
  const c = p.exhausted ? "var(--dsw-alias-state-error-primary)" : p.healthy < p.total ? "var(--dsw-alias-state-warn-primary)" : "var(--dsw-alias-state-success-primary)";
3838
3848
  const provName = p.providerName || name;
3839
- const tail = p.lastUsedTail;
3840
- return /* @__PURE__ */ React.createElement("div", { key: name, className: "krot-pop-row", style: { display: "flex", flexDirection: "column", gap: "4px", padding: "6px 0", borderBottom: "1px solid var(--dsw-alias-border-subtle)" } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center" } }, /* @__PURE__ */ React.createElement("div", { className: "krot-pop-name" }, /* @__PURE__ */ React.createElement("span", { style: { width: "7px", height: "7px", borderRadius: "50%", background: c, flex: "none" } }), name, tail ? /* @__PURE__ */ React.createElement("span", { style: { marginLeft: "6px", fontSize: "11px", color: "var(--dsw-alias-label-tertiary)", fontFeatureSettings: '"tnum"', fontFamily: "monospace" } }, `...${tail}`) : null), /* @__PURE__ */ React.createElement("div", { className: "krot-pop-count", style: { color: c, fontWeight: 600 } }, `${p.healthy}/${p.total}`)), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "4px", marginTop: "2px" } }, /* @__PURE__ */ React.createElement(
3849
+ const activeTail = p.lastUsedTail;
3850
+ const pinnedTail = p.pinnedTail;
3851
+ const isPinned = Boolean(p.pinnedRef);
3852
+ const keysList = Array.isArray(p.keys) ? p.keys : [];
3853
+ const cacheHits = p.promptCacheHits || 0;
3854
+ return /* @__PURE__ */ React.createElement("div", { key: name, className: "krot-pop-row", style: { display: "flex", flexDirection: "column", gap: "5px", padding: "8px 0", borderBottom: "1px solid var(--dsw-alias-border-subtle)" } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center" } }, /* @__PURE__ */ React.createElement("div", { className: "krot-pop-name", style: { display: "flex", alignItems: "center", gap: "6px" } }, /* @__PURE__ */ React.createElement("span", { style: { width: "7px", height: "7px", borderRadius: "50%", background: c, flex: "none" } }), /* @__PURE__ */ React.createElement("strong", { style: { fontSize: "12px" } }, name)), /* @__PURE__ */ React.createElement("div", { className: "krot-pop-count", style: { color: c, fontWeight: 600, fontSize: "11px" } }, `${p.healthy}/${p.total}`)), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", fontSize: "11px", color: "var(--dsw-alias-label-tertiary)" } }, /* @__PURE__ */ React.createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: "4px" } }, /* @__PURE__ */ React.createElement("span", null, isPinned ? "📌" : "⚡"), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "monospace" } }, isPinned ? pinnedTail ? `...${pinnedTail} (pinned)` : p.pinnedRef : activeTail ? `...${activeTail}` : "auto")), cacheHits > 0 ? /* @__PURE__ */ React.createElement("span", { style: { fontSize: "10px", padding: "1px 4px", borderRadius: "3px", background: "var(--dsw-alias-bg-subtle, transparent)", color: "var(--dsw-alias-state-success-primary)" }, title: "Prompt cache hits" }, `⚡ ${cacheHits}`) : null), keysList.length > 1 ? /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "6px", marginTop: "2px" } }, /* @__PURE__ */ React.createElement(
3855
+ "select",
3856
+ {
3857
+ disabled: busy,
3858
+ value: p.pinnedRef || "",
3859
+ onChange: (e) => runAction("pin", provName, e.target.value),
3860
+ title: t("quickPinTitle"),
3861
+ style: {
3862
+ width: "100%",
3863
+ fontSize: "11px",
3864
+ padding: "3px 6px",
3865
+ borderRadius: "4px",
3866
+ border: "1px solid var(--dsw-alias-border-subtle)",
3867
+ background: "var(--dsw-alias-bg-subtle, transparent)",
3868
+ color: "inherit",
3869
+ fontFamily: "inherit",
3870
+ cursor: "pointer"
3871
+ }
3872
+ },
3873
+ /* @__PURE__ */ React.createElement("option", { value: "" }, `🔀 ${t("quickAuto")}`),
3874
+ keysList.map((k) => {
3875
+ const marker = k.pinned ? "📌 " : k.active ? "▶ " : "";
3876
+ const status = k.paused ? " [paused]" : k.cooling ? " [cooling]" : "";
3877
+ return /* @__PURE__ */ React.createElement("option", { key: k.ref, value: k.ref }, `${marker}...${k.tail || k.ref.slice(-4)}${status}`);
3878
+ })
3879
+ )) : null, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: "4px", marginTop: "3px" } }, /* @__PURE__ */ React.createElement(
3841
3880
  "button",
3842
3881
  {
3843
3882
  type: "button",
@@ -3847,7 +3886,7 @@ window.__ModuleLoader__.load({
3847
3886
  runAction("skip", provName, p.lastUsed);
3848
3887
  },
3849
3888
  title: t("quickSkipTitle"),
3850
- style: { padding: "2px 6px", fontSize: "11px", borderRadius: "4px", border: "1px solid var(--dsw-alias-border-subtle)", background: "transparent", cursor: "pointer", color: "inherit" }
3889
+ style: { flex: 1, padding: "2px 6px", fontSize: "11px", borderRadius: "4px", border: "1px solid var(--dsw-alias-border-subtle)", background: "transparent", cursor: "pointer", color: "inherit" }
3851
3890
  },
3852
3891
  `⏭️ ${t("quickSkip")}`
3853
3892
  ), /* @__PURE__ */ React.createElement(
@@ -3860,7 +3899,7 @@ window.__ModuleLoader__.load({
3860
3899
  runAction("reset", provName, p.lastUsed);
3861
3900
  },
3862
3901
  title: t("quickResetTitle"),
3863
- style: { padding: "2px 6px", fontSize: "11px", borderRadius: "4px", border: "1px solid var(--dsw-alias-border-subtle)", background: "transparent", cursor: "pointer", color: "inherit" }
3902
+ style: { flex: 1, padding: "2px 6px", fontSize: "11px", borderRadius: "4px", border: "1px solid var(--dsw-alias-border-subtle)", background: "transparent", cursor: "pointer", color: "inherit" }
3864
3903
  },
3865
3904
  `🔄 ${t("quickReset")}`
3866
3905
  ), /* @__PURE__ */ React.createElement(
@@ -3873,7 +3912,7 @@ window.__ModuleLoader__.load({
3873
3912
  runAction("pause", provName, p.lastUsed);
3874
3913
  },
3875
3914
  title: t("quickPauseTitle"),
3876
- style: { padding: "2px 6px", fontSize: "11px", borderRadius: "4px", border: "1px solid var(--dsw-alias-border-subtle)", background: "transparent", cursor: "pointer", color: "inherit" }
3915
+ style: { flex: 1, padding: "2px 6px", fontSize: "11px", borderRadius: "4px", border: "1px solid var(--dsw-alias-border-subtle)", background: "transparent", cursor: "pointer", color: "inherit" }
3877
3916
  },
3878
3917
  `⏸️ ${t("quickPause")}`
3879
3918
  )));
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
 
@@ -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, {
@@ -35,8 +35,8 @@ export function registerQuickActionRoutes(ctx, deps) {
35
35
  const provider = typeof body?.provider === 'string' ? body.provider.trim() : '';
36
36
  const ref = typeof body?.ref === 'string' ? body.ref.trim() : null;
37
37
 
38
- if (!action || !['skip', 'reset', 'pause'].includes(action)) {
39
- json(res, 400, { error: { code: 'invalid-action', message: 'action must be skip, reset, or pause' } });
38
+ if (!action || !['skip', 'reset', 'pause', 'pin'].includes(action)) {
39
+ json(res, 400, { error: { code: 'invalid-action', message: 'action must be skip, reset, pause, or pin' } });
40
40
  return;
41
41
  }
42
42
 
@@ -164,6 +164,30 @@ export function registerQuickActionRoutes(ctx, deps) {
164
164
  });
165
165
  return;
166
166
  }
167
+ if (action === 'pin') {
168
+ const targetRef = typeof ref === 'string' && ref.trim().length > 0 ? ref.trim() : null;
169
+ if (targetRef && targetPool.refs && !targetPool.refs.includes(targetRef)) {
170
+ json(res, 400, { error: { code: 'invalid-key', message: `Key ${targetRef} not in pool` } });
171
+ return;
172
+ }
173
+ targetPool.pinnedRef = targetRef;
174
+ if (targetRef) {
175
+ targetPool.state.lastUsed = targetRef;
176
+ }
177
+
178
+ if (typeof pushEvent === 'function') {
179
+ pushEvent(targetPool, targetRef || '*', targetRef ? 'KEY_PINNED' : 'KEY_UNPINNED', 0);
180
+ }
181
+
182
+ json(res, 200, {
183
+ ok: true,
184
+ action: 'pin',
185
+ provider,
186
+ pinnedRef: targetPool.pinnedRef,
187
+ pinnedTail: targetPool.pinnedRef ? keyTail(targetPool.pinnedRef) : null,
188
+ });
189
+ return;
190
+ }
167
191
  },
168
192
  }));
169
193
  }
@@ -249,6 +249,17 @@ ctx.effect(() => getWebServer(ctx).register({
249
249
  modelQuota: quotaAware ? { configured: true, exhaustedKeys: quotaExhausted } : null,
250
250
  lastUsed: pool.state.lastUsed ?? null,
251
251
  lastUsedTail: pool.state.lastUsed ? keyTail(pool.state.lastUsed) : null,
252
+ pinnedRef: pool.pinnedRef ?? null,
253
+ pinnedTail: pool.pinnedRef ? keyTail(pool.pinnedRef) : null,
254
+ promptCacheHits: pool.state.promptCacheHits ?? 0,
255
+ keys: pool.refs.map((r) => ({
256
+ ref: r,
257
+ tail: keyTail(r),
258
+ active: pool.state.lastUsed === r,
259
+ pinned: pool.pinnedRef === r,
260
+ paused: isKeyPaused(pool, r),
261
+ cooling: (pool.state.failedUntil?.get(r) ?? 0) > now,
262
+ })),
252
263
  };
253
264
  }
254
265
  let circuitOpenAny = false;
@@ -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.87",
3
+ "version": "0.8.89",
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": [