bullswarm 0.2.0 → 0.3.0
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/connectors/claude-code.json +2 -1
- package/connectors/command-code.json +3 -1
- package/package.json +1 -1
- package/src/lib/route.js +54 -6
- package/src/lib/state.js +5 -0
package/package.json
CHANGED
package/src/lib/route.js
CHANGED
|
@@ -30,11 +30,28 @@ export function elapsedPct(meter, now = Date.now()) {
|
|
|
30
30
|
return Math.min(100, ((now - start) / ms) * 100);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
export const DEFAULT_COST_RANK = 5;
|
|
34
|
+
|
|
35
|
+
/** Coerce costRank safely: missing/NaN/non-number → DEFAULT_COST_RANK. */
|
|
36
|
+
export function costOf(pool) {
|
|
37
|
+
const c = Number(pool?.costRank);
|
|
38
|
+
return Number.isFinite(c) ? c : DEFAULT_COST_RANK;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Surplus = elapsed% − used%; higher = more quota about to expire.
|
|
43
|
+
*
|
|
44
|
+
* Shape tolerance (production bug fix): buildPools produces FLAT fields
|
|
45
|
+
* (pool.pace / pool.usedPct), while tests and legacy callers build
|
|
46
|
+
* pool.meter. Accept both. Never return NaN — a non-finite score would
|
|
47
|
+
* break the sort comparator's totality and make picks order-dependent.
|
|
48
|
+
*/
|
|
34
49
|
export function paceScore(pool, now = Date.now()) {
|
|
35
|
-
|
|
50
|
+
if (Number.isFinite(pool?.pace)) return pool.pace;
|
|
51
|
+
const meter = pool?.meter;
|
|
36
52
|
if (!meter || meter.type === 'none' || meter.usedPct == null) return 0;
|
|
37
|
-
|
|
53
|
+
const s = elapsedPct(meter, now) - Number(meter.usedPct);
|
|
54
|
+
return Number.isFinite(s) ? s : 0;
|
|
38
55
|
}
|
|
39
56
|
|
|
40
57
|
export function isQuarantined(pool, now = Date.now()) {
|
|
@@ -44,7 +61,15 @@ export function isQuarantined(pool, now = Date.now()) {
|
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
export function isExhausted(pool) {
|
|
47
|
-
|
|
64
|
+
// Flat shape (buildPools) first, legacy meter shape second. A stale
|
|
65
|
+
// meterSource reading must not permanently exclude a pool: if the reading
|
|
66
|
+
// is stale-labeled and older than the window could explain, trust the pool
|
|
67
|
+
// may have reset — the next live poll will decide.
|
|
68
|
+
const used = pool?.usedPct ?? pool?.meter?.usedPct;
|
|
69
|
+
if (!Number.isFinite(used)) return false;
|
|
70
|
+
if (used < 100) return false;
|
|
71
|
+
if (pool.meterSource === 'stale') return false;
|
|
72
|
+
return true;
|
|
48
73
|
}
|
|
49
74
|
|
|
50
75
|
/**
|
|
@@ -112,18 +137,41 @@ export function pickPool(lane, pools, opts = {}) {
|
|
|
112
137
|
|
|
113
138
|
let winnerEntry;
|
|
114
139
|
if (incumbentEntry) {
|
|
115
|
-
// R3+R4: challenger needs margin
|
|
140
|
+
// R3+R4: challenger needs margin. The cost guard protects the incumbent
|
|
141
|
+
// ONLY while it is a reasonable steward of its quota: a distressed
|
|
142
|
+
// incumbent (deep negative surplus) forfeits cost protection, and
|
|
143
|
+
// equal-cost challengers may displace (strict < caused permanent
|
|
144
|
+
// lock-in between same-rank pools).
|
|
145
|
+
const INCUMBENT_DISTRESS = -20;
|
|
146
|
+
const incumbentDistressed =
|
|
147
|
+
incumbentEntry.pace <= INCUMBENT_DISTRESS || isExhausted(incumbentEntry.pool);
|
|
116
148
|
const challenger = scored.find(
|
|
117
149
|
(e) =>
|
|
118
150
|
e !== incumbentEntry &&
|
|
119
151
|
e.pace >= incumbentEntry.pace + INCUMBENCY_MARGIN &&
|
|
120
|
-
(e.pool
|
|
152
|
+
(incumbentDistressed || costOf(e.pool) <= costOf(incumbentEntry.pool)),
|
|
121
153
|
);
|
|
122
154
|
winnerEntry = challenger ?? incumbentEntry;
|
|
123
155
|
} else {
|
|
124
156
|
winnerEntry = scored[0];
|
|
125
157
|
}
|
|
126
158
|
|
|
159
|
+
// R5: the caller wins its lane only when no eligible delegate remains —
|
|
160
|
+
// or when the caller's own pool entry genuinely wins on merit. Dispatching
|
|
161
|
+
// the caller to itself as a subprocess is always wrong.
|
|
162
|
+
const isCaller =
|
|
163
|
+
winnerEntry.pool.isCaller === true ||
|
|
164
|
+
winnerEntry.pool.connector?.flags?.isCaller === true ||
|
|
165
|
+
(callerName && winnerEntry.pool.name === callerName);
|
|
166
|
+
if (isCaller) {
|
|
167
|
+
return {
|
|
168
|
+
pick: null,
|
|
169
|
+
keepOnClaude: true,
|
|
170
|
+
why: 'caller pool won the lane; keep work in-session',
|
|
171
|
+
candidates,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
127
175
|
return {
|
|
128
176
|
pick: { pool: winnerEntry.pool.name, connector: winnerEntry.pool },
|
|
129
177
|
keepOnClaude: false,
|
package/src/lib/state.js
CHANGED
|
@@ -54,6 +54,11 @@ export function quarantinePool(state, poolName, reason, now = Date.now()) {
|
|
|
54
54
|
const until = now + 10 * 60_000;
|
|
55
55
|
state.pools[poolName] ??= {};
|
|
56
56
|
state.pools[poolName].quarantine = { until, reason };
|
|
57
|
+
// A quarantined pool cannot hold incumbency: it isn't serving work, and
|
|
58
|
+
// keeping the flag would lock the lane against its return.
|
|
59
|
+
for (const [lane, name] of Object.entries(state.incumbents ?? {})) {
|
|
60
|
+
if (name === poolName) delete state.incumbents[lane];
|
|
61
|
+
}
|
|
57
62
|
return until;
|
|
58
63
|
}
|
|
59
64
|
|